@flayerlabs/gamemode-gate 0.7.0 → 0.8.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/DEPLOY.md +6 -1
- package/README.md +4 -2
- package/dist/admission.d.ts +107 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +201 -0
- package/dist/admission.js.map +1 -0
- package/dist/chain/secure.d.ts +17 -0
- package/dist/chain/secure.d.ts.map +1 -1
- package/dist/chain/secure.js +40 -0
- package/dist/chain/secure.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/ip-blocks.d.ts +50 -0
- package/dist/ip-blocks.d.ts.map +1 -0
- package/dist/ip-blocks.js +125 -0
- package/dist/ip-blocks.js.map +1 -0
- package/dist/join-ticket-ledger.d.ts +135 -0
- package/dist/join-ticket-ledger.d.ts.map +1 -0
- package/dist/join-ticket-ledger.js +190 -0
- package/dist/join-ticket-ledger.js.map +1 -0
- package/dist/main.d.ts +16 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +36 -2
- package/dist/main.js.map +1 -1
- package/dist/player-join-tickets.d.ts +10 -0
- package/dist/player-join-tickets.d.ts.map +1 -1
- package/dist/player-join-tickets.js +27 -14
- package/dist/player-join-tickets.js.map +1 -1
- package/dist/registry.d.ts +1 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +4 -4
- package/dist/registry.js.map +1 -1
- package/dist/server.d.ts +41 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +147 -12
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +33 -1
- package/dist/store.js.map +1 -1
- package/dist/turnstile.d.ts +37 -1
- package/dist/turnstile.d.ts.map +1 -1
- package/dist/turnstile.js +82 -2
- package/dist/turnstile.js.map +1 -1
- package/dist/version.js +2 -2
- package/package.json +3 -3
- package/src/admission.ts +273 -0
- package/src/chain/secure.ts +52 -0
- package/src/index.ts +34 -2
- package/src/ip-blocks.ts +154 -0
- package/src/join-ticket-ledger.ts +325 -0
- package/src/main.ts +56 -2
- package/src/player-join-tickets.ts +34 -19
- package/src/registry.ts +6 -3
- package/src/server.ts +188 -15
- package/src/store.ts +33 -1
- package/src/turnstile.ts +96 -2
- package/src/version.ts +2 -2
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import type { Pool, PoolClient } from 'pg';
|
|
3
|
+
import { isAddress } from 'viem';
|
|
4
|
+
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
5
|
+
import type { PlayerJoinTicket } from '@flayerlabs/gamemode-spec/live';
|
|
6
|
+
import { issuePlayerJoinTicket, readPlayerJoinTicket } from './player-join-tickets.js';
|
|
7
|
+
import { inTransaction } from './store.js';
|
|
8
|
+
|
|
9
|
+
const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
10
|
+
const RATE_WINDOW_MS = 60_000;
|
|
11
|
+
const LIST_LIMIT = 100;
|
|
12
|
+
|
|
13
|
+
/** Enough for a player whose connection keeps dropping; far too few to farm seats with. */
|
|
14
|
+
export const DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE = 20;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Where a ticket is in its life.
|
|
18
|
+
*
|
|
19
|
+
* `issued` is waiting to be presented. `expired` was never presented in time. `consumed` is a seat
|
|
20
|
+
* the game server accepted and has not given back. `released` is finished, by the server's word.
|
|
21
|
+
*/
|
|
22
|
+
export type JoinTicketStatus = 'issued' | 'expired' | 'consumed' | 'released';
|
|
23
|
+
|
|
24
|
+
export interface JoinTicketRecord {
|
|
25
|
+
jti: string;
|
|
26
|
+
roundId: string;
|
|
27
|
+
player: PlayerId;
|
|
28
|
+
audience: string;
|
|
29
|
+
issuedAt: number;
|
|
30
|
+
expiresAt: number;
|
|
31
|
+
consumedAt: number | null;
|
|
32
|
+
releasedAt: number | null;
|
|
33
|
+
status: JoinTicketStatus;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How freely one wallet may be handed tickets. The gate operator's choice, per game. */
|
|
37
|
+
export interface JoinTicketPolicy {
|
|
38
|
+
/**
|
|
39
|
+
* The most tickets one wallet may hold open in a round: issued and unexpired, or consumed and not
|
|
40
|
+
* yet released. Omitted, there is no limit.
|
|
41
|
+
*
|
|
42
|
+
* Set it only if your game server releases every seat it consumes, and releases them all when it
|
|
43
|
+
* restarts. A server that consumes and never releases locks its own players out for the round.
|
|
44
|
+
*/
|
|
45
|
+
maxOpenPerPlayer?: number;
|
|
46
|
+
/** Tickets one wallet may be issued in a round in any minute. Defaults to twenty. */
|
|
47
|
+
maxIssuedPerMinutePerPlayer?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type JoinTicketIssueRefusal = 'ticket.rate_limited' | 'ticket.seat_open';
|
|
51
|
+
|
|
52
|
+
export type JoinTicketIssueResult =
|
|
53
|
+
| { ok: true; ticket: PlayerJoinTicket }
|
|
54
|
+
| { ok: false; refuse: JoinTicketIssueRefusal };
|
|
55
|
+
|
|
56
|
+
export type JoinTicketConsumeRefusal =
|
|
57
|
+
| 'ticket.invalid'
|
|
58
|
+
| 'ticket.no_such_round'
|
|
59
|
+
| 'ticket.wrong_audience'
|
|
60
|
+
| 'ticket.expired'
|
|
61
|
+
| 'ticket.already_consumed'
|
|
62
|
+
| 'ticket.released';
|
|
63
|
+
|
|
64
|
+
/** The seat a server just accepted, and any other seats the same wallet still holds. */
|
|
65
|
+
export interface JoinTicketConsumption {
|
|
66
|
+
ticket: JoinTicketRecord;
|
|
67
|
+
/**
|
|
68
|
+
* Other consumed, unreleased tickets for this wallet in this round, oldest first.
|
|
69
|
+
*
|
|
70
|
+
* A reconnecting player arrives before the server has noticed the old socket died. Close these
|
|
71
|
+
* connections and release them, and the wallet holds one seat.
|
|
72
|
+
*/
|
|
73
|
+
otherOpen: string[];
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export type JoinTicketConsumeResult =
|
|
77
|
+
| ({ ok: true } & JoinTicketConsumption)
|
|
78
|
+
| { ok: false; refuse: JoinTicketConsumeRefusal };
|
|
79
|
+
|
|
80
|
+
/** Exactly one selector: one ticket, one wallet's tickets, or every ticket in the round. */
|
|
81
|
+
export type JoinTicketReleaseSelector = { jti: string } | { player: PlayerId } | { all: true };
|
|
82
|
+
|
|
83
|
+
export type JoinTicketReleaseResult =
|
|
84
|
+
| { ok: true; released: string[] }
|
|
85
|
+
| { ok: false; refuse: 'ticket.invalid' | 'ticket.no_such_round' };
|
|
86
|
+
|
|
87
|
+
export interface JoinTicketCounts {
|
|
88
|
+
/** Every ticket this wallet has been issued in the round. */
|
|
89
|
+
issued: number;
|
|
90
|
+
/** Seats the game server consumed and has not released. */
|
|
91
|
+
open: number;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface IssueInput {
|
|
95
|
+
awardToken: string;
|
|
96
|
+
roundId: string;
|
|
97
|
+
player: PlayerId;
|
|
98
|
+
audience: string;
|
|
99
|
+
closesAt: number;
|
|
100
|
+
now: number;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
interface StoredTicket {
|
|
104
|
+
jti: string;
|
|
105
|
+
round_id: string;
|
|
106
|
+
player: string;
|
|
107
|
+
audience: string;
|
|
108
|
+
issued_at: string;
|
|
109
|
+
expires_at: string;
|
|
110
|
+
consumed_at: string | null;
|
|
111
|
+
released_at: string | null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const COLUMNS = 'jti, round_id, player, audience, issued_at, expires_at, consumed_at, released_at';
|
|
115
|
+
|
|
116
|
+
function statusOf(row: StoredTicket, now: number): JoinTicketStatus {
|
|
117
|
+
if (row.released_at !== null) return 'released';
|
|
118
|
+
if (row.consumed_at !== null) return 'consumed';
|
|
119
|
+
return Number(row.expires_at) > now ? 'issued' : 'expired';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const record = (row: StoredTicket, now: number): JoinTicketRecord => ({
|
|
123
|
+
jti: row.jti,
|
|
124
|
+
roundId: row.round_id,
|
|
125
|
+
player: row.player,
|
|
126
|
+
audience: row.audience,
|
|
127
|
+
issuedAt: Number(row.issued_at),
|
|
128
|
+
expiresAt: Number(row.expires_at),
|
|
129
|
+
consumedAt: row.consumed_at === null ? null : Number(row.consumed_at),
|
|
130
|
+
releasedAt: row.released_at === null ? null : Number(row.released_at),
|
|
131
|
+
status: statusOf(row, now),
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
function positiveInteger(value: number | undefined, name: string): number | null {
|
|
135
|
+
if (value === undefined) return null;
|
|
136
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* The gate's record of every join ticket it signs, and what became of each.
|
|
142
|
+
*
|
|
143
|
+
* A ticket by itself is a stateless MAC: the gate that signed it cannot say how many a wallet
|
|
144
|
+
* holds, and every game server has to keep its own replay cache to stop one being presented twice.
|
|
145
|
+
* Writing each ticket down moves both jobs here. Issue is rationed per wallet, consume succeeds
|
|
146
|
+
* exactly once, and release is how the server that owns the connection says the seat is free.
|
|
147
|
+
*
|
|
148
|
+
* Scoped by game in every statement, so another game's round reads as no such round.
|
|
149
|
+
*/
|
|
150
|
+
export class JoinTickets {
|
|
151
|
+
private readonly maxOpenPerPlayer: number | null;
|
|
152
|
+
private readonly maxIssuedPerMinutePerPlayer: number;
|
|
153
|
+
|
|
154
|
+
constructor(
|
|
155
|
+
private readonly pool: Pool,
|
|
156
|
+
private readonly gameId: string,
|
|
157
|
+
policy: JoinTicketPolicy = {},
|
|
158
|
+
) {
|
|
159
|
+
this.maxOpenPerPlayer = positiveInteger(policy.maxOpenPerPlayer, 'tickets.maxOpenPerPlayer');
|
|
160
|
+
this.maxIssuedPerMinutePerPlayer =
|
|
161
|
+
positiveInteger(policy.maxIssuedPerMinutePerPlayer, 'tickets.maxIssuedPerMinutePerPlayer') ??
|
|
162
|
+
DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Sign a ticket and write it down, or refuse a wallet that already holds too many. */
|
|
166
|
+
async issue(input: IssueInput): Promise<JoinTicketIssueResult> {
|
|
167
|
+
const player = input.player.toLowerCase();
|
|
168
|
+
const jti = randomUUID();
|
|
169
|
+
// Signed before the transaction: it is pure, and its bounds checks should fail before a lock.
|
|
170
|
+
const ticket = issuePlayerJoinTicket({ ...input, player, gameId: this.gameId, jti });
|
|
171
|
+
|
|
172
|
+
return inTransaction(this.pool, async (tx) => {
|
|
173
|
+
// Two requests from one wallet must not both read "under the limit" and both insert.
|
|
174
|
+
await lockPlayer(tx, input.roundId, player);
|
|
175
|
+
const counts = await tx.query<{ recent: string; open: string }>(
|
|
176
|
+
`select count(*) filter (where issued_at > $3)::text as recent,
|
|
177
|
+
count(*) filter (
|
|
178
|
+
where released_at is null and (consumed_at is not null or expires_at > $4)
|
|
179
|
+
)::text as open
|
|
180
|
+
from join_tickets where round_id = $1 and player = $2`,
|
|
181
|
+
[input.roundId, player, input.now - RATE_WINDOW_MS, input.now],
|
|
182
|
+
);
|
|
183
|
+
const { recent, open } = counts.rows[0]!;
|
|
184
|
+
if (Number(recent) >= this.maxIssuedPerMinutePerPlayer) return { ok: false, refuse: 'ticket.rate_limited' };
|
|
185
|
+
if (this.maxOpenPerPlayer !== null && Number(open) >= this.maxOpenPerPlayer) {
|
|
186
|
+
return { ok: false, refuse: 'ticket.seat_open' };
|
|
187
|
+
}
|
|
188
|
+
await tx.query(
|
|
189
|
+
`insert into join_tickets (jti, round_id, player, audience, issued_at, expires_at)
|
|
190
|
+
values ($1, $2, $3, $4, $5, $6)`,
|
|
191
|
+
[jti, input.roundId, player, input.audience, input.now, ticket.expiresAt],
|
|
192
|
+
);
|
|
193
|
+
return { ok: true, ticket };
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Accept a ticket a browser presented, exactly once.
|
|
199
|
+
*
|
|
200
|
+
* The gate verifies the MAC, the round, the audience and the expiry, so a game server in any
|
|
201
|
+
* language can admit a player with one request and no cryptography of its own.
|
|
202
|
+
*/
|
|
203
|
+
async consume(
|
|
204
|
+
input: { awardToken: string; roundId: string; ticket: unknown; audience?: unknown },
|
|
205
|
+
now = Date.now(),
|
|
206
|
+
): Promise<JoinTicketConsumeResult> {
|
|
207
|
+
if (typeof input.roundId !== 'string' || !IDENTIFIER.test(input.roundId)) {
|
|
208
|
+
return { ok: false, refuse: 'ticket.invalid' };
|
|
209
|
+
}
|
|
210
|
+
if (input.audience !== undefined && typeof input.audience !== 'string') {
|
|
211
|
+
return { ok: false, refuse: 'ticket.invalid' };
|
|
212
|
+
}
|
|
213
|
+
const claims = readPlayerJoinTicket(input.ticket, input.awardToken);
|
|
214
|
+
if (!claims || claims.gameId !== this.gameId) return { ok: false, refuse: 'ticket.invalid' };
|
|
215
|
+
if (!(await this.roundExists(input.roundId))) return { ok: false, refuse: 'ticket.no_such_round' };
|
|
216
|
+
if (claims.roundId !== input.roundId) return { ok: false, refuse: 'ticket.invalid' };
|
|
217
|
+
// A ticket minted for one server must not seat a player on another that shares the gate.
|
|
218
|
+
if (input.audience !== undefined && input.audience !== claims.audience) {
|
|
219
|
+
return { ok: false, refuse: 'ticket.wrong_audience' };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const updated = await this.pool.query<StoredTicket>(
|
|
223
|
+
`update join_tickets set consumed_at = $3
|
|
224
|
+
where jti = $1 and round_id = $2 and consumed_at is null and released_at is null and expires_at > $3
|
|
225
|
+
returning ${COLUMNS}`,
|
|
226
|
+
[claims.jti, input.roundId, now],
|
|
227
|
+
);
|
|
228
|
+
const row = updated.rows[0];
|
|
229
|
+
if (!row) {
|
|
230
|
+
const existing = await this.pool.query<StoredTicket>(
|
|
231
|
+
`select ${COLUMNS} from join_tickets where jti = $1 and round_id = $2`,
|
|
232
|
+
[claims.jti, input.roundId],
|
|
233
|
+
);
|
|
234
|
+
const found = existing.rows[0];
|
|
235
|
+
// A valid MAC with no row was signed by something other than this ledger.
|
|
236
|
+
if (!found) return { ok: false, refuse: 'ticket.invalid' };
|
|
237
|
+
if (found.released_at !== null) return { ok: false, refuse: 'ticket.released' };
|
|
238
|
+
if (found.consumed_at !== null) return { ok: false, refuse: 'ticket.already_consumed' };
|
|
239
|
+
return { ok: false, refuse: 'ticket.expired' };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const others = await this.pool.query<{ jti: string }>(
|
|
243
|
+
`select jti from join_tickets
|
|
244
|
+
where round_id = $1 and player = $2 and jti <> $3 and consumed_at is not null and released_at is null
|
|
245
|
+
order by consumed_at, jti`,
|
|
246
|
+
[input.roundId, row.player, row.jti],
|
|
247
|
+
);
|
|
248
|
+
return { ok: true, ticket: record(row, now), otherOpen: others.rows.map((other) => other.jti) };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Give seats back. Releasing an unpresented ticket voids it.
|
|
253
|
+
*
|
|
254
|
+
* Idempotent: a ticket already released is not released again and is not an error, so a server
|
|
255
|
+
* can call this from every code path that ends a connection.
|
|
256
|
+
*/
|
|
257
|
+
async release(roundId: string, selector: unknown, now = Date.now()): Promise<JoinTicketReleaseResult> {
|
|
258
|
+
const parsed = parseSelector(selector);
|
|
259
|
+
if (typeof roundId !== 'string' || !IDENTIFIER.test(roundId) || !parsed) {
|
|
260
|
+
return { ok: false, refuse: 'ticket.invalid' };
|
|
261
|
+
}
|
|
262
|
+
if (!(await this.roundExists(roundId))) return { ok: false, refuse: 'ticket.no_such_round' };
|
|
263
|
+
|
|
264
|
+
const [filter, value] =
|
|
265
|
+
'jti' in parsed
|
|
266
|
+
? ['and jti = $3', parsed.jti]
|
|
267
|
+
: 'player' in parsed
|
|
268
|
+
? ['and player = $3', parsed.player.toLowerCase()]
|
|
269
|
+
: ['', null];
|
|
270
|
+
const released = await this.pool.query<{ jti: string }>(
|
|
271
|
+
`update join_tickets set released_at = $2
|
|
272
|
+
where round_id = $1 and released_at is null ${filter}
|
|
273
|
+
returning jti`,
|
|
274
|
+
value === null ? [roundId, now] : [roundId, now, value],
|
|
275
|
+
);
|
|
276
|
+
return { ok: true, released: released.rows.map((row) => row.jti) };
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/** One wallet's most recent tickets in a round, newest first. Null for a round this game does not own. */
|
|
280
|
+
async list(roundId: string, player: string, now = Date.now()): Promise<JoinTicketRecord[] | null> {
|
|
281
|
+
if (!(await this.roundExists(roundId))) return null;
|
|
282
|
+
const rows = await this.pool.query<StoredTicket>(
|
|
283
|
+
`select ${COLUMNS} from join_tickets
|
|
284
|
+
where round_id = $1 and player = $2
|
|
285
|
+
order by issued_at desc, jti
|
|
286
|
+
limit ${LIST_LIMIT}`,
|
|
287
|
+
[roundId, player.toLowerCase()],
|
|
288
|
+
);
|
|
289
|
+
return rows.rows.map((row) => record(row, now));
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
async counts(roundId: string, player: string): Promise<JoinTicketCounts> {
|
|
293
|
+
const result = await this.pool.query<{ issued: string; open: string }>(
|
|
294
|
+
`select count(*)::text as issued,
|
|
295
|
+
count(*) filter (where consumed_at is not null and released_at is null)::text as open
|
|
296
|
+
from join_tickets where round_id = $1 and player = $2`,
|
|
297
|
+
[roundId, player.toLowerCase()],
|
|
298
|
+
);
|
|
299
|
+
return { issued: Number(result.rows[0]!.issued), open: Number(result.rows[0]!.open) };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private async roundExists(roundId: string): Promise<boolean> {
|
|
303
|
+
const round = await this.pool.query(
|
|
304
|
+
`select 1 from rounds where id = $1 and game_id = $2 and score_authority = 'external-server'`,
|
|
305
|
+
[roundId, this.gameId],
|
|
306
|
+
);
|
|
307
|
+
return round.rows.length > 0;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function lockPlayer(tx: PoolClient, roundId: string, player: string): Promise<unknown> {
|
|
312
|
+
return tx.query(`select pg_advisory_xact_lock(hashtextextended($1, 0))`, [`gamemode_join_ticket:${roundId}:${player}`]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function parseSelector(value: unknown): JoinTicketReleaseSelector | null {
|
|
316
|
+
if (typeof value !== 'object' || value === null) return null;
|
|
317
|
+
const { jti, player, all } = value as Record<string, unknown>;
|
|
318
|
+
// Exactly one, so a body that names a ticket and also says "all" cannot empty a round by accident.
|
|
319
|
+
if ([jti, player, all].filter((entry) => entry !== undefined).length !== 1) return null;
|
|
320
|
+
if (jti !== undefined) return typeof jti === 'string' && IDENTIFIER.test(jti) ? { jti } : null;
|
|
321
|
+
if (player !== undefined) {
|
|
322
|
+
return typeof player === 'string' && isAddress(player, { strict: false }) ? { player } : null;
|
|
323
|
+
}
|
|
324
|
+
return all === true ? { all: true } : null;
|
|
325
|
+
}
|
package/src/main.ts
CHANGED
|
@@ -4,7 +4,13 @@ import { createPublicClient, http, isAddress, parseAbi, type PublicClient } from
|
|
|
4
4
|
import type { FastifyInstance } from 'fastify';
|
|
5
5
|
import type { GameModule } from '@flayerlabs/gamemode-spec';
|
|
6
6
|
import type { SpendToken } from '@flayerlabs/gamemode-spec/live';
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
MAX_TRUSTED_PROXY_HOPS,
|
|
9
|
+
createGate,
|
|
10
|
+
type AnnouncedSpendToken,
|
|
11
|
+
type GateAnnouncement,
|
|
12
|
+
type PriceFor,
|
|
13
|
+
} from './server.js';
|
|
8
14
|
import { Ledger } from './ledger.js';
|
|
9
15
|
import { Claims } from './claims.js';
|
|
10
16
|
import { Sessions } from './sessions.js';
|
|
@@ -16,6 +22,13 @@ import { resolveEconomy } from './economy.js';
|
|
|
16
22
|
import { deploymentFor, type GateDeployment } from './chain/chains.js';
|
|
17
23
|
import { assertSecureConfig } from './chain/secure.js';
|
|
18
24
|
import { RegistryPrices } from './chain/registry-prices.js';
|
|
25
|
+
import {
|
|
26
|
+
isTurnstileTestingSecret,
|
|
27
|
+
resolveTurnstileEnv,
|
|
28
|
+
turnstileAdmission,
|
|
29
|
+
type TurnstileConfig,
|
|
30
|
+
} from './turnstile.js';
|
|
31
|
+
import { composeAdmit, createRateLimitAdmit } from './admission.js';
|
|
19
32
|
|
|
20
33
|
/**
|
|
21
34
|
* A production gate from an environment, assembled and checked.
|
|
@@ -68,6 +81,12 @@ export interface ResolvedGateConfig {
|
|
|
68
81
|
pairedTokenCapHeadroomPercent: number;
|
|
69
82
|
/** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
|
|
70
83
|
settler: `0x${string}` | null;
|
|
84
|
+
/** Reverse proxies between the internet and this process; decides whose address a request has. */
|
|
85
|
+
trustedProxyHops: number;
|
|
86
|
+
/** The Turnstile widget that guards sign-in, or null when sign-in needs no evidence. */
|
|
87
|
+
turnstile: TurnstileConfig | null;
|
|
88
|
+
/** Sign-ins one address may make in a minute. Zero is no limit. */
|
|
89
|
+
sessionsPerIpPerMinute: number;
|
|
71
90
|
}
|
|
72
91
|
|
|
73
92
|
export interface SpendTokenPricing {
|
|
@@ -180,6 +199,15 @@ function address(env: Env, name: string): `0x${string}` | null {
|
|
|
180
199
|
* would otherwise quote; tokens neither here nor approved on the
|
|
181
200
|
* manager's PairedTokenRegistry are refused.
|
|
182
201
|
* PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
|
|
202
|
+
* TRUSTED_PROXY_HOPS default 0; reverse proxies in front of the gate (2 on Railway, 3 with
|
|
203
|
+
* a CDN in front of that). Decides the address admission policy sees.
|
|
204
|
+
* SESSIONS_PER_IP_PER_MINUTE sign-ins one address may make in a minute. Default 30 when
|
|
205
|
+
* TRUSTED_PROXY_HOPS is set, otherwise 0 (no limit): until the gate is
|
|
206
|
+
* told its proxies it cannot tell one player's address from another's.
|
|
207
|
+
* TURNSTILE_SITE_KEY optional; with TURNSTILE_SECRET, sign-in requires a Turnstile token
|
|
208
|
+
* TURNSTILE_SECRET optional; set together with TURNSTILE_SITE_KEY or not at all
|
|
209
|
+
* TURNSTILE_ACTION default game-session
|
|
210
|
+
* TURNSTILE_HOSTNAMES default the host of SIGN_IN_DOMAIN; comma-separated
|
|
183
211
|
*/
|
|
184
212
|
export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
185
213
|
const chainId = Number(required(env, 'CHAIN_ID'));
|
|
@@ -237,13 +265,19 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
|
237
265
|
);
|
|
238
266
|
if (allowedOrigins.length === 0) throw new Error('ALLOWED_ORIGINS names no origins');
|
|
239
267
|
|
|
268
|
+
const signInDomain = required(env, 'SIGN_IN_DOMAIN');
|
|
269
|
+
const trustedProxyHops = integer(env, 'TRUSTED_PROXY_HOPS', 0);
|
|
270
|
+
if (trustedProxyHops > MAX_TRUSTED_PROXY_HOPS) {
|
|
271
|
+
throw new Error(`TRUSTED_PROXY_HOPS must be at most ${MAX_TRUSTED_PROXY_HOPS}`);
|
|
272
|
+
}
|
|
273
|
+
|
|
240
274
|
return {
|
|
241
275
|
chainId,
|
|
242
276
|
rpcUrl: required(env, 'RPC_URL'),
|
|
243
277
|
signerPrivateKey: signerPrivateKey as `0x${string}`,
|
|
244
278
|
databaseUrl: required(env, 'DATABASE_URL'),
|
|
245
279
|
sessionSecret: required(env, 'SESSION_SECRET'),
|
|
246
|
-
signInDomain
|
|
280
|
+
signInDomain,
|
|
247
281
|
gateOrigin: required(env, 'GATE_ORIGIN'),
|
|
248
282
|
allowedOrigins,
|
|
249
283
|
port: integer(env, 'PORT', 8790),
|
|
@@ -263,6 +297,9 @@ export function resolveGateEnv(env: Env): ResolvedGateConfig {
|
|
|
263
297
|
spendTokens: spendTokens(env),
|
|
264
298
|
pairedTokenCapHeadroomPercent: integer(env, 'PAIRED_TOKEN_CAP_HEADROOM_PERCENT', 25),
|
|
265
299
|
settler: address(env, 'SETTLER'),
|
|
300
|
+
trustedProxyHops,
|
|
301
|
+
turnstile: resolveTurnstileEnv(env, signInDomain),
|
|
302
|
+
sessionsPerIpPerMinute: integer(env, 'SESSIONS_PER_IP_PER_MINUTE', trustedProxyHops > 0 ? 30 : 0),
|
|
266
303
|
};
|
|
267
304
|
}
|
|
268
305
|
|
|
@@ -399,6 +436,20 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
399
436
|
const signer = new PayloadSigner(config.signerPrivateKey, config.chainId, config.deployment.spendGatedCalculator);
|
|
400
437
|
const settler = config.settler ?? signer.address;
|
|
401
438
|
|
|
439
|
+
const turnstile = config.turnstile ? turnstileAdmission(config.turnstile) : null;
|
|
440
|
+
if (config.turnstile && isTurnstileTestingSecret(config.turnstile.secret)) {
|
|
441
|
+
// Loud because it is silent otherwise: the widget renders and every caller passes.
|
|
442
|
+
console.warn('TURNSTILE_SECRET is a Cloudflare testing secret: sign-in is not protected');
|
|
443
|
+
}
|
|
444
|
+
// The rate limit first: a caller over budget is refused before a Turnstile token is spent on them.
|
|
445
|
+
const rateLimit =
|
|
446
|
+
config.sessionsPerIpPerMinute > 0
|
|
447
|
+
? createRateLimitAdmit({
|
|
448
|
+
limits: { session: { perIp: { max: config.sessionsPerIpPerMinute, windowMs: 60_000 } } },
|
|
449
|
+
})
|
|
450
|
+
: undefined;
|
|
451
|
+
const admit = rateLimit || turnstile ? composeAdmit(rateLimit, turnstile?.admit) : undefined;
|
|
452
|
+
|
|
402
453
|
const announce = (): GateAnnouncement => ({
|
|
403
454
|
chainId: config.chainId,
|
|
404
455
|
contracts: {
|
|
@@ -420,6 +471,7 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
420
471
|
accepting: true,
|
|
421
472
|
publicLaunchesOpen: true,
|
|
422
473
|
privateLaunches: false,
|
|
474
|
+
...(turnstile ? { admission: turnstile.admission } : {}),
|
|
423
475
|
});
|
|
424
476
|
|
|
425
477
|
const app = createGate({
|
|
@@ -448,6 +500,8 @@ export async function startGate<Config, State, Event, Action, PublicView, Player
|
|
|
448
500
|
}),
|
|
449
501
|
settlement: new Settlement(pool, ledger, client, config.deployment.spendGatedCalculator),
|
|
450
502
|
allowedOrigins: config.allowedOrigins,
|
|
503
|
+
trustedProxyHops: config.trustedProxyHops,
|
|
504
|
+
...(admit ? { admit } : {}),
|
|
451
505
|
announce,
|
|
452
506
|
readiness: createGateReadiness({ origin: config.gateOrigin, chainId: config.chainId,
|
|
453
507
|
positionManager: config.deployment.positionManager, signer, pool, client }),
|
|
@@ -31,6 +31,8 @@ interface IssuePlayerJoinTicketOptions extends VerifyPlayerJoinTicketOptions {
|
|
|
31
31
|
player: PlayerId;
|
|
32
32
|
closesAt: number;
|
|
33
33
|
now: number;
|
|
34
|
+
/** The ticket's unique ID, when the caller has already written it down. Random when omitted. */
|
|
35
|
+
jti?: string;
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
interface TicketBody extends VerifiedPlayerJoin {
|
|
@@ -66,6 +68,7 @@ export function issuePlayerJoinTicket(options: IssuePlayerJoinTicketOptions): Pl
|
|
|
66
68
|
if (!Number.isSafeInteger(options.closesAt) || options.closesAt <= now) {
|
|
67
69
|
throw new RangeError('the round must still be open');
|
|
68
70
|
}
|
|
71
|
+
if (options.jti !== undefined && !ID.test(options.jti)) throw new RangeError('jti must be a valid identifier');
|
|
69
72
|
const expiresAt = Math.min(now + PLAYER_JOIN_TICKET_TTL_MS, options.closesAt);
|
|
70
73
|
const claims: TicketBody = {
|
|
71
74
|
v: 1,
|
|
@@ -76,29 +79,27 @@ export function issuePlayerJoinTicket(options: IssuePlayerJoinTicketOptions): Pl
|
|
|
76
79
|
audience: options.audience,
|
|
77
80
|
issuedAt: now,
|
|
78
81
|
expiresAt,
|
|
79
|
-
jti: randomUUID(),
|
|
82
|
+
jti: options.jti ?? randomUUID(),
|
|
80
83
|
};
|
|
81
84
|
const body = Buffer.from(JSON.stringify(claims)).toString('base64url');
|
|
82
85
|
return { ticket: `gmj1.${body}.${mac(body, options.awardToken)}`, expiresAt };
|
|
83
86
|
}
|
|
84
87
|
|
|
85
|
-
/**
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
ticket.length > MAX_TICKET_LENGTH
|
|
95
|
-
) {
|
|
88
|
+
/**
|
|
89
|
+
* Check a ticket's MAC and shape, and nothing about when or where it is being presented.
|
|
90
|
+
*
|
|
91
|
+
* For the gate's own ledger, which then decides freshness and scope from the row it wrote. A
|
|
92
|
+
* game server must use {@link verifyPlayerJoinTicket}: a ticket that only passes this is authentic,
|
|
93
|
+
* not acceptable.
|
|
94
|
+
*/
|
|
95
|
+
export function readPlayerJoinTicket(ticket: unknown, awardToken: string): VerifiedPlayerJoin | null {
|
|
96
|
+
if (awardToken.length < 32 || typeof ticket !== 'string' || ticket.length === 0 || ticket.length > MAX_TICKET_LENGTH) {
|
|
96
97
|
return null;
|
|
97
98
|
}
|
|
98
99
|
const parts = ticket.split('.');
|
|
99
100
|
if (parts.length !== 3 || parts[0] !== 'gmj1') return null;
|
|
100
101
|
const [, body, signature] = parts as [string, string, string];
|
|
101
|
-
if (!body || !signature || !matches(body, signature,
|
|
102
|
+
if (!body || !signature || !matches(body, signature, awardToken)) return null;
|
|
102
103
|
|
|
103
104
|
let value: Record<string, unknown>;
|
|
104
105
|
try {
|
|
@@ -109,7 +110,6 @@ export function verifyPlayerJoinTicket(
|
|
|
109
110
|
return null;
|
|
110
111
|
}
|
|
111
112
|
|
|
112
|
-
const now = Date.now();
|
|
113
113
|
if (
|
|
114
114
|
value.v !== 1 ||
|
|
115
115
|
value.kind !== 'player-join' ||
|
|
@@ -128,11 +128,6 @@ export function verifyPlayerJoinTicket(
|
|
|
128
128
|
!Number.isSafeInteger(value.expiresAt) ||
|
|
129
129
|
typeof value.jti !== 'string' ||
|
|
130
130
|
!ID.test(value.jti) ||
|
|
131
|
-
value.gameId !== options.gameId ||
|
|
132
|
-
value.roundId !== options.roundId ||
|
|
133
|
-
value.audience !== options.audience ||
|
|
134
|
-
value.issuedAt > now + CLOCK_SKEW_MS ||
|
|
135
|
-
value.expiresAt <= now ||
|
|
136
131
|
value.expiresAt <= value.issuedAt ||
|
|
137
132
|
value.expiresAt - value.issuedAt > PLAYER_JOIN_TICKET_TTL_MS
|
|
138
133
|
) {
|
|
@@ -149,3 +144,23 @@ export function verifyPlayerJoinTicket(
|
|
|
149
144
|
jti: value.jti,
|
|
150
145
|
};
|
|
151
146
|
}
|
|
147
|
+
|
|
148
|
+
/** Verify an opaque browser join ticket before binding a socket to its wallet. */
|
|
149
|
+
export function verifyPlayerJoinTicket(
|
|
150
|
+
ticket: unknown,
|
|
151
|
+
options: VerifyPlayerJoinTicketOptions,
|
|
152
|
+
): VerifiedPlayerJoin | null {
|
|
153
|
+
const claims = readPlayerJoinTicket(ticket, options.awardToken);
|
|
154
|
+
const now = Date.now();
|
|
155
|
+
if (
|
|
156
|
+
!claims ||
|
|
157
|
+
claims.gameId !== options.gameId ||
|
|
158
|
+
claims.roundId !== options.roundId ||
|
|
159
|
+
claims.audience !== options.audience ||
|
|
160
|
+
claims.issuedAt > now + CLOCK_SKEW_MS ||
|
|
161
|
+
claims.expiresAt <= now
|
|
162
|
+
) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
return claims;
|
|
166
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { Pool } from 'pg';
|
|
2
2
|
import {
|
|
3
|
+
MAX_REVIEWED_ORIGINS,
|
|
3
4
|
parseGameServerOrigins,
|
|
4
5
|
type GameRegistration,
|
|
5
6
|
type GameSecurityProfile,
|
|
@@ -98,10 +99,12 @@ export function gateOrigin(value: unknown): string {
|
|
|
98
99
|
}
|
|
99
100
|
|
|
100
101
|
/** Validate the reviewed origins a creator-owned authoritative game server may use. */
|
|
101
|
-
export function gameServerOrigins(value: unknown): readonly string[] {
|
|
102
|
-
const origins = parseGameServerOrigins(value);
|
|
102
|
+
export function gameServerOrigins(value: unknown, max = MAX_REVIEWED_ORIGINS): readonly string[] {
|
|
103
|
+
const origins = parseGameServerOrigins(value, max);
|
|
103
104
|
if (origins === null) {
|
|
104
|
-
throw new InvalidRegistration(
|
|
105
|
+
throw new InvalidRegistration(
|
|
106
|
+
`game server origins must be at most ${max} distinct public HTTPS origins`,
|
|
107
|
+
);
|
|
105
108
|
}
|
|
106
109
|
return origins;
|
|
107
110
|
}
|