@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.
Files changed (60) 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/registry.d.ts +1 -1
  32. package/dist/registry.d.ts.map +1 -1
  33. package/dist/registry.js +4 -4
  34. package/dist/registry.js.map +1 -1
  35. package/dist/server.d.ts +41 -3
  36. package/dist/server.d.ts.map +1 -1
  37. package/dist/server.js +147 -12
  38. package/dist/server.js.map +1 -1
  39. package/dist/store.d.ts +1 -1
  40. package/dist/store.d.ts.map +1 -1
  41. package/dist/store.js +33 -1
  42. package/dist/store.js.map +1 -1
  43. package/dist/turnstile.d.ts +37 -1
  44. package/dist/turnstile.d.ts.map +1 -1
  45. package/dist/turnstile.js +82 -2
  46. package/dist/turnstile.js.map +1 -1
  47. package/dist/version.js +2 -2
  48. package/package.json +3 -3
  49. package/src/admission.ts +273 -0
  50. package/src/chain/secure.ts +52 -0
  51. package/src/index.ts +34 -2
  52. package/src/ip-blocks.ts +154 -0
  53. package/src/join-ticket-ledger.ts +325 -0
  54. package/src/main.ts +56 -2
  55. package/src/player-join-tickets.ts +34 -19
  56. package/src/registry.ts +6 -3
  57. package/src/server.ts +188 -15
  58. package/src/store.ts +33 -1
  59. package/src/turnstile.ts +96 -2
  60. package/src/version.ts +2 -2
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import { isAddress } from 'viem';
5
5
  import type { Pool } from 'pg';
6
6
  import type { Command, GameModule, PlayerId } from '@flayerlabs/gamemode-spec';
7
7
  import { isRefusal } from '@flayerlabs/gamemode-spec';
8
+ import { MAX_GAME_SERVER_ORIGINS } from '@flayerlabs/gamemode-spec/embed';
8
9
  import type {
9
10
  EconomyBalance,
10
11
  PresenceState,
@@ -28,9 +29,10 @@ import { resolveEconomy } from './economy.js';
28
29
  import { encodeHookData } from './chain/signer.js';
29
30
  import { hasBearerToken } from './bearer.js';
30
31
  import { GameServerAwards, type GameServerAwardRequest } from './game-server-awards.js';
31
- import { issuePlayerJoinTicket } from './player-join-tickets.js';
32
+ import { JoinTickets, type JoinTicketPolicy } from './join-ticket-ledger.js';
32
33
  import { gameServerOrigins } from './registry.js';
33
34
  import { GATE_VERSION } from './version.js';
35
+ import { IpBlocks } from './ip-blocks.js';
34
36
 
35
37
  /**
36
38
  * The gate's door.
@@ -106,6 +108,17 @@ interface GateBaseOptions {
106
108
  * Omitted, everything is admitted, which is what a laptop and a test want.
107
109
  */
108
110
  admit?: Admit;
111
+ /**
112
+ * How many reverse proxies stand between the internet and this process.
113
+ *
114
+ * Zero, the default, reports the socket's own address, which is right only when nothing proxies
115
+ * the gate. Count every address between the player and the process: Railway is two (its public
116
+ * edge, then an internal router), and a CDN in front adds one. Set too low, players share a
117
+ * proxy's address and an IP policy refuses them together. Set too high, a caller chooses their
118
+ * own address with a header. Only safe when the process cannot be reached except through those
119
+ * proxies: a caller who connects directly can forge the whole chain.
120
+ */
121
+ trustedProxyHops?: number;
109
122
  /** The only cosmetic reaction IDs this game may broadcast. Omitted disables reactions. */
110
123
  reactionIds?: readonly string[];
111
124
  }
@@ -146,6 +159,8 @@ export interface GameServerGateOptions extends GateBaseOptions {
146
159
  awardToken: string;
147
160
  /** Reviewed HTTPS origins that may receive player join tickets. The gate origin is always allowed. */
148
161
  gameServerOrigins?: readonly string[];
162
+ /** How freely one wallet may be issued join tickets. Omitted, only the per-minute default applies. */
163
+ tickets?: JoinTicketPolicy;
149
164
  }
150
165
 
151
166
  /**
@@ -207,6 +222,20 @@ export interface GateAnnouncement {
207
222
  publicLaunchesOpen: boolean;
208
223
  /** This library has no private-lobby registrar; announced false so no page offers the toggle. */
209
224
  privateLaunches: boolean;
225
+ /**
226
+ * What the trusted parent must send as session evidence. Absent means sign-in needs none.
227
+ *
228
+ * Announced because the parent renders the widget, not the gate: without the site key in public
229
+ * config, a gate that verifies Turnstile refuses every sign-in from a page that cannot know to
230
+ * show one.
231
+ */
232
+ admission?: AdmissionAnnouncement;
233
+ }
234
+
235
+ /** The public half of a gate's admission policy. Never carries a secret. */
236
+ export interface AdmissionAnnouncement {
237
+ /** Render a Cloudflare Turnstile widget with this site key and action; send its token as `evidence`. */
238
+ turnstile?: { siteKey: string; action: string };
210
239
  }
211
240
 
212
241
  /**
@@ -220,12 +249,18 @@ export interface ServedGateAnnouncement extends GateAnnouncement {
220
249
 
221
250
  /** Where an admission check is being made. Enough to tell a sign-in from a spend. */
222
251
  export interface AdmissionRequest {
223
- /** `session` on sign-in, `action` on gameplay, `claim` when allowance is about to be committed. */
224
- at: 'session' | 'action' | 'claim';
252
+ /**
253
+ * `session` on sign-in, `ticket` when a join ticket for an external game server is requested,
254
+ * `action` on gameplay, `claim` when allowance is about to be committed.
255
+ */
256
+ at: 'session' | 'ticket' | 'action' | 'claim';
225
257
  /** Null at `session`, because nobody has proved who they are yet. */
226
258
  player: PlayerId | null;
227
259
  roundId: string | null;
228
- /** Straight from the socket, so a limiter can key on it. Absent behind some proxies. */
260
+ /**
261
+ * The caller's address, so a limiter can key on it. The socket's own unless `trustedProxyHops`
262
+ * says how many proxies to read past; behind a proxy with that left at zero this is the proxy.
263
+ */
229
264
  ip: string | null;
230
265
  /** Opaque and operator-defined: a captcha solution, a device token, whatever was sent. */
231
266
  evidence: unknown;
@@ -243,7 +278,10 @@ export type Admit = (request: AdmissionRequest) => Admission | Promise<Admission
243
278
 
244
279
  const ADMIT_ALL: Admit = () => ({ ok: true });
245
280
 
246
- /** What the socket says, which is only the truth when nothing is proxying in front of the gate. */
281
+ /** More hops than any real deployment has; a larger number is a typo, not a topology. */
282
+ export const MAX_TRUSTED_PROXY_HOPS = 8;
283
+
284
+ /** Fastify resolves this through `trustProxy`, which `trustedProxyHops` sets. */
247
285
  const ipOf = (request: FastifyRequest): string | null => request.ip || null;
248
286
 
249
287
  /** Bigint has no JSON form, so terms cross as decimal strings, exactly as the economy balance does. */
@@ -309,6 +347,7 @@ type AdoptionResult<Config, State, Event, Action, PublicView, PlayerView> =
309
347
  interface AwardEndpoint {
310
348
  token: string;
311
349
  awards: GameServerAwards;
350
+ tickets: JoinTickets;
312
351
  ticketAudiences: ReadonlySet<string>;
313
352
  }
314
353
 
@@ -372,12 +411,17 @@ export function createGameServerGate(options: GameServerGateOptions): FastifyIns
372
411
  typeof maximum === 'function'
373
412
  ? async (launch) => ({ maxPointsPerPlayer: await maximum(launch) })
374
413
  : { maxPointsPerPlayer: maximum };
375
- const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins)]);
376
- const { gameId, maxPointsPerPlayer: _maximum, awardToken, gameServerOrigins: _origins, ...base } = options;
414
+ const ticketAudiences = new Set([options.sessions.origin, ...gameServerOrigins(options.gameServerOrigins, MAX_GAME_SERVER_ORIGINS)]);
415
+ const { gameId, maxPointsPerPlayer: _maximum, awardToken, gameServerOrigins: _origins, tickets, ...base } = options;
377
416
  return createGateRuntime(
378
417
  { ...base, game: gameServerModule(gameId), config },
379
418
  'external-server',
380
- { token: awardToken, awards: new GameServerAwards(options.pool, gameId), ticketAudiences },
419
+ {
420
+ token: awardToken,
421
+ awards: new GameServerAwards(options.pool, gameId),
422
+ tickets: new JoinTickets(options.pool, gameId, tickets),
423
+ ticketAudiences,
424
+ },
381
425
  );
382
426
  }
383
427
 
@@ -387,7 +431,23 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
387
431
  awardEndpoint?: AwardEndpoint,
388
432
  ): FastifyInstance {
389
433
  const { pool, game, sessions, claims, discovery, settlement } = options;
390
- const admit = options.admit ?? ADMIT_ALL;
434
+ const operatorAdmit = options.admit ?? ADMIT_ALL;
435
+ const ipBlocks = new IpBlocks(pool);
436
+ const startedAt = Date.now();
437
+ const refused: Record<AdmissionRequest['at'], number> = { session: 0, ticket: 0, action: 0, claim: 0 };
438
+ /**
439
+ * The block list first, then the operator's policy, and every refusal counted.
440
+ *
441
+ * The block list is the gate's own because its record is the gate's own table. It is consulted
442
+ * before the operator's policy so a blocked caller costs no Turnstile check and no rate budget.
443
+ */
444
+ const admit: Admit = async (request) => {
445
+ const admission: Admission = (await ipBlocks.blocks(request.ip))
446
+ ? { ok: false, reason: 'you cannot play from this network' }
447
+ : await operatorAdmit(request);
448
+ if (!admission.ok) refused[request.at] += 1;
449
+ return admission;
450
+ };
391
451
  // Rules config is persisted as JSON, so a function here can only be the documented factory.
392
452
  const configIsFactory = typeof options.config === 'function';
393
453
  const allowedReactions = new Set(options.reactionIds ?? []);
@@ -398,8 +458,20 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
398
458
  if (!Number.isSafeInteger(settlementTailMs) || settlementTailMs <= 0) {
399
459
  throw new RangeError('settlementTailMs must be a positive number of milliseconds');
400
460
  }
461
+ const trustedProxyHops = options.trustedProxyHops ?? 0;
462
+ if (!Number.isSafeInteger(trustedProxyHops) || trustedProxyHops < 0 || trustedProxyHops > MAX_TRUSTED_PROXY_HOPS) {
463
+ throw new RangeError(`trustedProxyHops must be an integer from 0 to ${MAX_TRUSTED_PROXY_HOPS}`);
464
+ }
401
465
  // A body limit costs nothing and is the cheapest protection against an endpoint that parses.
402
- const app = Fastify({ logger: process.env.GATE_LOG === '1', bodyLimit: 64 * 1024 });
466
+ const app = Fastify({
467
+ logger: process.env.GATE_LOG === '1',
468
+ bodyLimit: 64 * 1024,
469
+ // A hop count, never `true`: trusting every forwarded header lets a caller name their own address.
470
+ // Passed as a function because Fastify refuses a bare number (from 5.12 it trusts nothing for
471
+ // one), on the grounds that a hop count cannot tell a proxy from a direct caller. That holds
472
+ // here too, and is why the option is documented as safe only behind the proxies it counts.
473
+ ...(trustedProxyHops > 0 ? { trustProxy: (_address: string, hop: number) => hop < trustedProxyHops } : {}),
474
+ });
403
475
  const rooms = new Map<string, Room<Config, State, Event, Action, PublicView, PlayerView>>();
404
476
  type Watcher = {
405
477
  viewer: PlayerId | null;
@@ -676,6 +748,9 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
676
748
  total: row.total,
677
749
  })),
678
750
  lastRoundAt: totals.last_round_at === null ? null : Number(totals.last_round_at),
751
+ // Counted in memory since this process started, not lifetime: a refusal is not worth a write.
752
+ // Counts only, never reasons, so a caller cannot read a threshold off a public route.
753
+ admission: { since: startedAt, refused: { ...refused } },
679
754
  // The same version `/config` reports, so a dashboard already reading stats needs no second call.
680
755
  gateVersion: GATE_VERSION,
681
756
  };
@@ -825,16 +900,111 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
825
900
  if (typeof audience !== 'string' || !awardEndpoint.ticketAudiences.has(audience)) {
826
901
  return reply.code(400).send({ message: 'that game server is not registered' });
827
902
  }
828
- await recordPlaySession(pool, id, who);
829
- return issuePlayerJoinTicket({
903
+ const admission = await admit({ at: 'ticket', player: who, roundId: id, ip: ipOf(request), evidence: undefined });
904
+ if (!admission.ok) return reply.code(403).send({ message: admission.reason });
905
+ const issued = await awardEndpoint.tickets.issue({
830
906
  awardToken: awardEndpoint.token,
831
907
  player: who,
832
- gameId: game.id,
833
908
  roundId: id,
834
909
  audience,
835
910
  closesAt: room.window.closesAt,
836
911
  now,
837
912
  });
913
+ if (!issued.ok) {
914
+ // Sentences a player can act on. The thresholds stay here.
915
+ return reply.code(429).send({
916
+ message:
917
+ issued.refuse === 'ticket.seat_open'
918
+ ? 'you are already connected to this game'
919
+ : 'too many tries, wait a minute',
920
+ });
921
+ }
922
+ await recordPlaySession(pool, id, who);
923
+ return issued.ticket;
924
+ });
925
+
926
+ /**
927
+ * Accept a ticket a browser presented to the game server, exactly once.
928
+ *
929
+ * The gate does the whole check, so a server needs no MAC code and no replay cache: a second
930
+ * presentation of the same ticket is refused here.
931
+ */
932
+ app.post('/internal/rounds/:id/tickets/consume', async (request, reply) => {
933
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
934
+ return reply.code(401).send({ message: 'game server authentication failed' });
935
+ }
936
+ const body = (request.body ?? {}) as { ticket?: unknown; audience?: unknown };
937
+ const result = await awardEndpoint.tickets.consume({
938
+ awardToken: awardEndpoint.token,
939
+ roundId: (request.params as { id: string }).id,
940
+ ticket: body.ticket,
941
+ audience: body.audience,
942
+ });
943
+ if (!result.ok) {
944
+ const status = result.refuse === 'ticket.invalid' ? 400 : result.refuse === 'ticket.no_such_round' ? 404 : 409;
945
+ return reply.code(status).send({ refuse: result.refuse });
946
+ }
947
+ return reply.header('cache-control', 'no-store').send({ ticket: result.ticket, otherOpen: result.otherOpen });
948
+ });
949
+
950
+ /** Give seats back: one ticket, one wallet's tickets, or every ticket in the round. */
951
+ app.post('/internal/rounds/:id/tickets/release', async (request, reply) => {
952
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
953
+ return reply.code(401).send({ message: 'game server authentication failed' });
954
+ }
955
+ const result = await awardEndpoint.tickets.release((request.params as { id: string }).id, request.body);
956
+ if (!result.ok) {
957
+ return reply.code(result.refuse === 'ticket.invalid' ? 400 : 404).send({ refuse: result.refuse });
958
+ }
959
+ return { released: result.released };
960
+ });
961
+
962
+ /** What one wallet has been issued in a round, newest first, so a server can see who minted what. */
963
+ app.get('/internal/rounds/:id/tickets', async (request, reply) => {
964
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
965
+ return reply.code(401).send({ message: 'game server authentication failed' });
966
+ }
967
+ const wallet = (request.query as { player?: unknown }).player;
968
+ if (typeof wallet !== 'string' || !isAddress(wallet, { strict: false })) {
969
+ return reply.code(400).send({ refuse: 'ticket.invalid' });
970
+ }
971
+ const tickets = await awardEndpoint.tickets.list((request.params as { id: string }).id, wallet);
972
+ if (!tickets) return reply.code(404).send({ refuse: 'ticket.no_such_round' });
973
+ return reply.header('cache-control', 'no-store').send({ tickets });
974
+ });
975
+
976
+ /**
977
+ * The gate's block list, for the game server that sees the abuse.
978
+ *
979
+ * A game server is where a cheat is noticed: it can block the address here and the gate stops
980
+ * issuing that address sessions and tickets, instead of the server refusing it one connection
981
+ * at a time.
982
+ */
983
+ app.get('/internal/admission/ip-blocks', async (request, reply) => {
984
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
985
+ return reply.code(401).send({ message: 'game server authentication failed' });
986
+ }
987
+ return reply.header('cache-control', 'no-store').send({ blocks: await ipBlocks.list() });
988
+ });
989
+
990
+ app.put('/internal/admission/ip-blocks', async (request, reply) => {
991
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
992
+ return reply.code(401).send({ message: 'game server authentication failed' });
993
+ }
994
+ const result = await ipBlocks.add((request.body ?? {}) as Record<string, unknown>);
995
+ if (!result.ok) {
996
+ return reply.code(result.refuse === 'ip_block.invalid' ? 400 : 409).send({ refuse: result.refuse });
997
+ }
998
+ return result.block;
999
+ });
1000
+
1001
+ app.delete('/internal/admission/ip-blocks', async (request, reply) => {
1002
+ if (!hasBearerToken(request.headers.authorization, awardEndpoint.token)) {
1003
+ return reply.code(401).send({ message: 'game server authentication failed' });
1004
+ }
1005
+ const removed = await ipBlocks.remove((request.query as { cidr?: unknown }).cidr);
1006
+ if (removed === null) return reply.code(400).send({ refuse: 'ip_block.invalid' });
1007
+ return { removed };
838
1008
  });
839
1009
 
840
1010
  app.post('/internal/rounds/:id/awards', async (request, reply) => {
@@ -887,10 +1057,13 @@ function createGateRuntime<Config, State, Event, Action, PublicView, PlayerView>
887
1057
 
888
1058
  // `spent` rises whenever settlement sees a purchase land, so this answer is only true at the
889
1059
  // moment it is read. Points are monotone; the wei beside them are not.
890
- const balance = await claims.economyBalance(result.standing.roundId, result.standing.player);
1060
+ const [balance, tickets] = await Promise.all([
1061
+ claims.economyBalance(result.standing.roundId, result.standing.player),
1062
+ awardEndpoint.tickets.counts(result.standing.roundId, result.standing.player),
1063
+ ]);
891
1064
  return reply
892
1065
  .header('cache-control', 'no-store')
893
- .send({ ...result.standing, economy: wireEconomy(balance) });
1066
+ .send({ ...result.standing, economy: wireEconomy(balance), tickets });
894
1067
  });
895
1068
  }
896
1069
 
package/src/store.ts CHANGED
@@ -160,10 +160,17 @@ create table if not exists registrations (
160
160
  game_server_origins text[] not null default '{}'::text[],
161
161
  disabled_at timestamptz,
162
162
  updated_at timestamptz not null default now(),
163
- constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 4),
163
+ constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 32),
164
164
  primary key (chain_id, coin)
165
165
  );
166
166
 
167
+ -- 0.7.1 raised the reviewed origin bound from four to thirty-two: twenty game servers plus the
168
+ -- other-chain gate origins a platform folds into the same list. The create table above only runs
169
+ -- for a new database, so rewrite the check in place for a gate that already has the table.
170
+ alter table registrations drop constraint if exists registrations_game_server_origins_bounded;
171
+ alter table registrations
172
+ add constraint registrations_game_server_origins_bounded check (cardinality(game_server_origins) <= 32);
173
+
167
174
  -- Accepted play sessions: one row per wallet and round, keyed by a hash of both so a wallet is never
168
175
  -- stored in the clear. Independent of scoring and of websocket reconnects. play_tracking records
169
176
  -- when this gate began counting, so a dashboard can tell "no plays" from "not yet tracked".
@@ -179,6 +186,31 @@ create table if not exists play_tracking (
179
186
  );
180
187
  insert into play_tracking(id, started_at) values (true, (extract(epoch from clock_timestamp()) * 1000)::bigint) on conflict do nothing;
181
188
 
189
+ -- Every join ticket the gate signs for an external game server, and what became of it. The wallet
190
+ -- is stored in the clear, as balances and award_events store it: the game server that owns the
191
+ -- connection asks by wallet. consumed_at is set once, by the server accepting the ticket;
192
+ -- released_at by the server giving the seat back, or voiding a ticket nobody presented.
193
+ create table if not exists join_tickets (
194
+ jti varchar(128) primary key check (jti <> ''),
195
+ round_id text not null references rounds(id) on delete cascade,
196
+ player text not null,
197
+ audience text not null check (audience <> ''),
198
+ issued_at bigint not null,
199
+ expires_at bigint not null check (expires_at > issued_at),
200
+ consumed_at bigint,
201
+ released_at bigint
202
+ );
203
+ create index if not exists join_tickets_round_player_idx on join_tickets(round_id, player, issued_at);
204
+
205
+ -- Addresses and networks the operator refuses. Postgres canonicalises and deduplicates the network;
206
+ -- the gate matches requests against an in-memory copy.
207
+ create table if not exists ip_blocks (
208
+ cidr cidr primary key,
209
+ reason varchar(200),
210
+ created_at bigint not null,
211
+ expires_at bigint
212
+ );
213
+
182
214
  -- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
183
215
  -- the record of why a state looks the way it does, which is what an incident needs.
184
216
  create table if not exists commands (
package/src/turnstile.ts CHANGED
@@ -1,10 +1,27 @@
1
- import type { Admit } from './server.js';
1
+ import type { AdmissionAnnouncement, Admit } from './server.js';
2
2
 
3
3
  const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
4
4
  const MAX_TOKEN_LENGTH = 2_048;
5
5
  const DEFAULT_TIMEOUT_MS = 8_000;
6
6
  const FAILURE = { ok: false, reason: 'human verification failed, please try again' } as const;
7
7
 
8
+ /**
9
+ * Cloudflare's published testing secrets: always pass, always fail, and "token already spent".
10
+ *
11
+ * Siteverify answers them with hostname `example.com` and no action, whatever page rendered the
12
+ * widget, so the hostname and action pins cannot hold and are skipped for exactly these secrets.
13
+ * Keyed on the configured secret, never on the response: a production secret rejects a testing
14
+ * token outright, so nothing a caller sends can switch the pins off.
15
+ */
16
+ const TESTING_SECRETS: ReadonlySet<string> = new Set([
17
+ '1x0000000000000000000000000000000AA',
18
+ '2x0000000000000000000000000000000AA',
19
+ '3x0000000000000000000000000000000AA',
20
+ ]);
21
+
22
+ /** Whether a secret is one of Cloudflare's testing secrets, which verify nothing about a caller. */
23
+ export const isTurnstileTestingSecret = (secret: string): boolean => TESTING_SECRETS.has(secret);
24
+
8
25
  export interface TurnstileAdmitOptions {
9
26
  secret: string;
10
27
  /** Exact hostnames on which the trusted parent may render the widget. */
@@ -32,6 +49,7 @@ export function createTurnstileAdmit(options: TurnstileAdmitOptions): Admit {
32
49
  throw new RangeError('Turnstile timeoutMs must be a positive safe integer');
33
50
  }
34
51
  const verify = options.fetch ?? globalThis.fetch;
52
+ const testing = isTurnstileTestingSecret(options.secret);
35
53
 
36
54
  return async (request) => {
37
55
  if (request.at !== 'session') return { ok: true };
@@ -58,8 +76,9 @@ export function createTurnstileAdmit(options: TurnstileAdmitOptions): Admit {
58
76
  });
59
77
  if (!response.ok) return FAILURE;
60
78
  const result = (await response.json()) as { success?: unknown; hostname?: unknown; action?: unknown };
79
+ if (result.success !== true) return FAILURE;
80
+ if (testing) return { ok: true };
61
81
  if (
62
- result.success !== true ||
63
82
  typeof result.hostname !== 'string' ||
64
83
  !hostnames.has(result.hostname.toLowerCase()) ||
65
84
  result.action !== options.action
@@ -72,3 +91,78 @@ export function createTurnstileAdmit(options: TurnstileAdmitOptions): Admit {
72
91
  }
73
92
  };
74
93
  }
94
+
95
+ type Env = Record<string, string | undefined>;
96
+
97
+ /** A Turnstile widget and the secret that verifies it, as one operator-supplied unit. */
98
+ export interface TurnstileConfig {
99
+ secret: string;
100
+ /** Public. The trusted parent renders the widget with it. */
101
+ siteKey: string;
102
+ action: string;
103
+ hostnames: readonly string[];
104
+ }
105
+
106
+ const DEFAULT_ACTION = 'game-session';
107
+ const SITE_KEY = /^[A-Za-z0-9_-]{1,128}$/;
108
+ // Cloudflare's own bound on a widget action.
109
+ const ACTION = /^[A-Za-z0-9_-]{1,32}$/;
110
+ const HOSTNAME = /^[a-z0-9]([a-z0-9.-]{0,251}[a-z0-9])?$/;
111
+
112
+ /**
113
+ * Read a Turnstile widget from the environment, or null when none is configured.
114
+ *
115
+ * TURNSTILE_SITE_KEY the widget's public site key, announced in `/config`
116
+ * TURNSTILE_SECRET the widget's secret, never announced
117
+ * TURNSTILE_ACTION default `game-session`
118
+ * TURNSTILE_HOSTNAMES comma-separated hostnames the widget may render on; defaults to the
119
+ * host of `signInDomain`, the page the player is looking at
120
+ *
121
+ * Half a pair is refused rather than ignored. A secret without a site key verifies tokens no page
122
+ * was told to fetch, so every sign-in fails; a site key without a secret shows a challenge nobody
123
+ * checks, which looks like protection and is none.
124
+ */
125
+ export function resolveTurnstileEnv(env: Env, signInDomain: string): TurnstileConfig | null {
126
+ const secret = env['TURNSTILE_SECRET'] || undefined;
127
+ const siteKey = env['TURNSTILE_SITE_KEY'] || undefined;
128
+ if (!secret && !siteKey) return null;
129
+ if (!secret || !siteKey) {
130
+ throw new Error('TURNSTILE_SECRET and TURNSTILE_SITE_KEY must be set together, from the same widget');
131
+ }
132
+ if (!SITE_KEY.test(siteKey)) throw new Error('TURNSTILE_SITE_KEY is not a Turnstile site key');
133
+ const action = env['TURNSTILE_ACTION'] || DEFAULT_ACTION;
134
+ if (!ACTION.test(action)) {
135
+ throw new Error('TURNSTILE_ACTION must be 1 to 32 letters, digits, underscores or hyphens');
136
+ }
137
+ // SIGN_IN_DOMAIN may carry a port in development; a Turnstile hostname never does.
138
+ const hostnames = (env['TURNSTILE_HOSTNAMES'] || signInDomain.replace(/:\d+$/, ''))
139
+ .split(',')
140
+ .map((hostname) => hostname.trim().toLowerCase())
141
+ .filter((hostname) => hostname.length > 0);
142
+ if (hostnames.length === 0 || hostnames.some((hostname) => !HOSTNAME.test(hostname))) {
143
+ throw new Error('TURNSTILE_HOSTNAMES must be comma-separated hostnames, without a scheme, port or path');
144
+ }
145
+ return { secret, siteKey, action, hostnames };
146
+ }
147
+
148
+ /**
149
+ * The two halves of one Turnstile policy: what the gate enforces and what it tells the parent.
150
+ *
151
+ * Built together so they cannot disagree. Pass `admit` to the gate and put `admission` in the
152
+ * announcement served at `/config`.
153
+ */
154
+ export function turnstileAdmission(
155
+ config: TurnstileConfig,
156
+ options: Pick<TurnstileAdmitOptions, 'fetch' | 'timeoutMs'> = {},
157
+ ): { admit: Admit; admission: AdmissionAnnouncement } {
158
+ if (!SITE_KEY.test(config.siteKey)) throw new RangeError('Turnstile siteKey is not a site key');
159
+ return {
160
+ admit: createTurnstileAdmit({
161
+ secret: config.secret,
162
+ hostnames: config.hostnames,
163
+ action: config.action,
164
+ ...options,
165
+ }),
166
+ admission: { turnstile: { siteKey: config.siteKey, action: config.action } },
167
+ };
168
+ }
package/src/version.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  // Generated by scripts/write-version.mjs from package.json at build time. Do not edit.
2
2
  /** The published version of @flayerlabs/gamemode-gate, as a literal so it survives any bundler. */
3
- export const GATE_VERSION: string = "0.7.0";
3
+ export const GATE_VERSION: string = "0.8.0";
4
4
  /** `@flayerlabs/gamemode-gate@<version>`: the text a build of this package can be searched for. */
5
- export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.7.0";
5
+ export const GATE_MARKER: string = "@flayerlabs/gamemode-gate@0.8.0";