@flayerlabs/gamemode-gate 0.7.1 → 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.
Files changed (55) hide show
  1. package/DEPLOY.md +6 -1
  2. package/README.md +4 -2
  3. package/dist/admission.d.ts +107 -0
  4. package/dist/admission.d.ts.map +1 -0
  5. package/dist/admission.js +201 -0
  6. package/dist/admission.js.map +1 -0
  7. package/dist/chain/secure.d.ts +17 -0
  8. package/dist/chain/secure.d.ts.map +1 -1
  9. package/dist/chain/secure.js +40 -0
  10. package/dist/chain/secure.js.map +1 -1
  11. package/dist/index.d.ts +10 -4
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +5 -2
  14. package/dist/index.js.map +1 -1
  15. package/dist/ip-blocks.d.ts +50 -0
  16. package/dist/ip-blocks.d.ts.map +1 -0
  17. package/dist/ip-blocks.js +125 -0
  18. package/dist/ip-blocks.js.map +1 -0
  19. package/dist/join-ticket-ledger.d.ts +135 -0
  20. package/dist/join-ticket-ledger.d.ts.map +1 -0
  21. package/dist/join-ticket-ledger.js +190 -0
  22. package/dist/join-ticket-ledger.js.map +1 -0
  23. package/dist/main.d.ts +16 -0
  24. package/dist/main.d.ts.map +1 -1
  25. package/dist/main.js +36 -2
  26. package/dist/main.js.map +1 -1
  27. package/dist/player-join-tickets.d.ts +10 -0
  28. package/dist/player-join-tickets.d.ts.map +1 -1
  29. package/dist/player-join-tickets.js +27 -14
  30. package/dist/player-join-tickets.js.map +1 -1
  31. package/dist/server.d.ts +41 -3
  32. package/dist/server.d.ts.map +1 -1
  33. package/dist/server.js +145 -11
  34. package/dist/server.js.map +1 -1
  35. package/dist/store.d.ts +1 -1
  36. package/dist/store.d.ts.map +1 -1
  37. package/dist/store.js +25 -0
  38. package/dist/store.js.map +1 -1
  39. package/dist/turnstile.d.ts +37 -1
  40. package/dist/turnstile.d.ts.map +1 -1
  41. package/dist/turnstile.js +82 -2
  42. package/dist/turnstile.js.map +1 -1
  43. package/dist/version.js +2 -2
  44. package/package.json +3 -3
  45. package/src/admission.ts +273 -0
  46. package/src/chain/secure.ts +52 -0
  47. package/src/index.ts +34 -2
  48. package/src/ip-blocks.ts +154 -0
  49. package/src/join-ticket-ledger.ts +325 -0
  50. package/src/main.ts +56 -2
  51. package/src/player-join-tickets.ts +34 -19
  52. package/src/server.ts +186 -14
  53. package/src/store.ts +25 -0
  54. package/src/turnstile.ts +96 -2
  55. 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 { createGate, type AnnouncedSpendToken, type GateAnnouncement, type PriceFor } from './server.js';
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: required(env, 'SIGN_IN_DOMAIN'),
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
- /** Verify an opaque browser join ticket before binding a socket to its wallet. */
86
- export function verifyPlayerJoinTicket(
87
- ticket: unknown,
88
- options: VerifyPlayerJoinTicketOptions,
89
- ): VerifiedPlayerJoin | null {
90
- if (
91
- options.awardToken.length < 32 ||
92
- typeof ticket !== 'string' ||
93
- ticket.length === 0 ||
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, options.awardToken)) return null;
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
+ }