@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,125 @@
1
+ import { BlockList, isIP } from 'node:net';
2
+ import { normaliseIp } from './admission.js';
3
+ /** A list longer than this is a job for a firewall in front of the gate, not a table inside it. */
4
+ export const MAX_IP_BLOCKS = 10_000;
5
+ const MAX_REASON_LENGTH = 200;
6
+ const DEFAULT_REFRESH_MS = 30_000;
7
+ const block = (row) => ({
8
+ cidr: row.cidr,
9
+ reason: row.reason,
10
+ createdAt: Number(row.created_at),
11
+ expiresAt: row.expires_at === null ? null : Number(row.expires_at),
12
+ });
13
+ /** An address or a network in CIDR form, or null. Host bits are allowed here and cleared on write. */
14
+ function parseCidr(value) {
15
+ if (typeof value !== 'string' || value.length === 0 || value.length > 64)
16
+ return null;
17
+ const [address, prefixText, ...rest] = value.trim().split('/');
18
+ if (!address || rest.length > 0)
19
+ return null;
20
+ const family = isIP(address);
21
+ if (family !== 4 && family !== 6)
22
+ return null;
23
+ const widest = family === 4 ? 32 : 128;
24
+ if (prefixText === undefined)
25
+ return { address, prefix: widest, family };
26
+ if (!/^\d{1,3}$/.test(prefixText))
27
+ return null;
28
+ const prefix = Number(prefixText);
29
+ // /0 would block every caller. That is turning the gate off, and not what a block list is for.
30
+ if (prefix < 1 || prefix > widest)
31
+ return null;
32
+ return { address, prefix, family };
33
+ }
34
+ /**
35
+ * Addresses and networks this gate refuses, kept in Postgres and matched in memory.
36
+ *
37
+ * The table is the record; the in-memory list is what a request is checked against, because an
38
+ * admission check runs on every action and a query per action is not affordable. The list is
39
+ * rebuilt after every write through this instance and at most every `refreshMs` otherwise, so a
40
+ * block written by another process takes effect within that interval.
41
+ */
42
+ export class IpBlocks {
43
+ pool;
44
+ refreshMs;
45
+ matcher = new BlockList();
46
+ loadedAt = -Infinity;
47
+ loading = null;
48
+ constructor(pool, refreshMs = DEFAULT_REFRESH_MS) {
49
+ this.pool = pool;
50
+ this.refreshMs = refreshMs;
51
+ }
52
+ /** Whether an address is blocked. Loads the list on first use and refreshes it when stale. */
53
+ async blocks(ip, now = Date.now()) {
54
+ if (!ip)
55
+ return false;
56
+ if (this.loadedAt === -Infinity)
57
+ await this.reload(now);
58
+ // A stale list is refreshed behind the request: the answer is at most one interval old.
59
+ else if (now - this.loadedAt >= this.refreshMs)
60
+ void this.reload(now).catch(() => undefined);
61
+ const address = normaliseIp(ip);
62
+ const family = isIP(address);
63
+ if (family !== 4 && family !== 6)
64
+ return false;
65
+ return this.matcher.check(address, family === 4 ? 'ipv4' : 'ipv6');
66
+ }
67
+ /** Block an address or network. Blocking it again updates the reason and expiry. */
68
+ async add(input, now = Date.now()) {
69
+ const parsed = parseCidr(input?.cidr);
70
+ const reason = input?.reason ?? null;
71
+ const expiresAt = input?.expiresAt ?? null;
72
+ if (!parsed ||
73
+ (reason !== null && (typeof reason !== 'string' || reason.length > MAX_REASON_LENGTH)) ||
74
+ (expiresAt !== null && (!Number.isSafeInteger(expiresAt) || expiresAt <= now))) {
75
+ return { ok: false, refuse: 'ip_block.invalid' };
76
+ }
77
+ const result = await this.pool.query(`insert into ip_blocks (cidr, reason, created_at, expires_at)
78
+ select network(set_masklen($1::inet, $2)), $3, $4, $5
79
+ where (select count(*) from ip_blocks) < $6
80
+ or exists (select 1 from ip_blocks where cidr = network(set_masklen($1::inet, $2)))
81
+ on conflict (cidr) do update set reason = excluded.reason, expires_at = excluded.expires_at
82
+ returning cidr::text, reason, created_at, expires_at`, [parsed.address, parsed.prefix, reason, now, expiresAt, MAX_IP_BLOCKS]);
83
+ const row = result.rows[0];
84
+ if (!row)
85
+ return { ok: false, refuse: 'ip_block.limit_exceeded' };
86
+ await this.reload(now);
87
+ return { ok: true, block: block(row) };
88
+ }
89
+ /** Remove a block. True if there was one. */
90
+ async remove(cidr, now = Date.now()) {
91
+ const parsed = parseCidr(cidr);
92
+ if (!parsed)
93
+ return null;
94
+ const result = await this.pool.query('delete from ip_blocks where cidr = network(set_masklen($1::inet, $2))', [parsed.address, parsed.prefix]);
95
+ await this.reload(now);
96
+ return (result.rowCount ?? 0) > 0;
97
+ }
98
+ /** Every block still in force, newest first. */
99
+ async list(now = Date.now()) {
100
+ const result = await this.pool.query(`select cidr::text, reason, created_at, expires_at from ip_blocks
101
+ where expires_at is null or expires_at > $1
102
+ order by created_at desc, cidr`, [now]);
103
+ return result.rows.map(block);
104
+ }
105
+ reload(now) {
106
+ this.loading ??= (async () => {
107
+ try {
108
+ // Expired rows are cleared here rather than by a timer: a gate nobody calls has no work to do.
109
+ await this.pool.query('delete from ip_blocks where expires_at is not null and expires_at <= $1', [now]);
110
+ const rows = await this.pool.query('select host(cidr) as address, masklen(cidr) as prefix, family(cidr) as family from ip_blocks');
111
+ const next = new BlockList();
112
+ for (const row of rows.rows) {
113
+ next.addSubnet(row.address, Number(row.prefix), Number(row.family) === 4 ? 'ipv4' : 'ipv6');
114
+ }
115
+ this.matcher = next;
116
+ this.loadedAt = now;
117
+ }
118
+ finally {
119
+ this.loading = null;
120
+ }
121
+ })();
122
+ return this.loading;
123
+ }
124
+ }
125
+ //# sourceMappingURL=ip-blocks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"ip-blocks.js","sourceRoot":"","sources":["../src/ip-blocks.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,135 @@
1
+ import type { Pool } from 'pg';
2
+ import type { PlayerId } from '@flayerlabs/gamemode-spec';
3
+ import type { PlayerJoinTicket } from '@flayerlabs/gamemode-spec/live';
4
+ /** Enough for a player whose connection keeps dropping; far too few to farm seats with. */
5
+ export declare const DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE = 20;
6
+ /**
7
+ * Where a ticket is in its life.
8
+ *
9
+ * `issued` is waiting to be presented. `expired` was never presented in time. `consumed` is a seat
10
+ * the game server accepted and has not given back. `released` is finished, by the server's word.
11
+ */
12
+ export type JoinTicketStatus = 'issued' | 'expired' | 'consumed' | 'released';
13
+ export interface JoinTicketRecord {
14
+ jti: string;
15
+ roundId: string;
16
+ player: PlayerId;
17
+ audience: string;
18
+ issuedAt: number;
19
+ expiresAt: number;
20
+ consumedAt: number | null;
21
+ releasedAt: number | null;
22
+ status: JoinTicketStatus;
23
+ }
24
+ /** How freely one wallet may be handed tickets. The gate operator's choice, per game. */
25
+ export interface JoinTicketPolicy {
26
+ /**
27
+ * The most tickets one wallet may hold open in a round: issued and unexpired, or consumed and not
28
+ * yet released. Omitted, there is no limit.
29
+ *
30
+ * Set it only if your game server releases every seat it consumes, and releases them all when it
31
+ * restarts. A server that consumes and never releases locks its own players out for the round.
32
+ */
33
+ maxOpenPerPlayer?: number;
34
+ /** Tickets one wallet may be issued in a round in any minute. Defaults to twenty. */
35
+ maxIssuedPerMinutePerPlayer?: number;
36
+ }
37
+ export type JoinTicketIssueRefusal = 'ticket.rate_limited' | 'ticket.seat_open';
38
+ export type JoinTicketIssueResult = {
39
+ ok: true;
40
+ ticket: PlayerJoinTicket;
41
+ } | {
42
+ ok: false;
43
+ refuse: JoinTicketIssueRefusal;
44
+ };
45
+ export type JoinTicketConsumeRefusal = 'ticket.invalid' | 'ticket.no_such_round' | 'ticket.wrong_audience' | 'ticket.expired' | 'ticket.already_consumed' | 'ticket.released';
46
+ /** The seat a server just accepted, and any other seats the same wallet still holds. */
47
+ export interface JoinTicketConsumption {
48
+ ticket: JoinTicketRecord;
49
+ /**
50
+ * Other consumed, unreleased tickets for this wallet in this round, oldest first.
51
+ *
52
+ * A reconnecting player arrives before the server has noticed the old socket died. Close these
53
+ * connections and release them, and the wallet holds one seat.
54
+ */
55
+ otherOpen: string[];
56
+ }
57
+ export type JoinTicketConsumeResult = ({
58
+ ok: true;
59
+ } & JoinTicketConsumption) | {
60
+ ok: false;
61
+ refuse: JoinTicketConsumeRefusal;
62
+ };
63
+ /** Exactly one selector: one ticket, one wallet's tickets, or every ticket in the round. */
64
+ export type JoinTicketReleaseSelector = {
65
+ jti: string;
66
+ } | {
67
+ player: PlayerId;
68
+ } | {
69
+ all: true;
70
+ };
71
+ export type JoinTicketReleaseResult = {
72
+ ok: true;
73
+ released: string[];
74
+ } | {
75
+ ok: false;
76
+ refuse: 'ticket.invalid' | 'ticket.no_such_round';
77
+ };
78
+ export interface JoinTicketCounts {
79
+ /** Every ticket this wallet has been issued in the round. */
80
+ issued: number;
81
+ /** Seats the game server consumed and has not released. */
82
+ open: number;
83
+ }
84
+ interface IssueInput {
85
+ awardToken: string;
86
+ roundId: string;
87
+ player: PlayerId;
88
+ audience: string;
89
+ closesAt: number;
90
+ now: number;
91
+ }
92
+ /**
93
+ * The gate's record of every join ticket it signs, and what became of each.
94
+ *
95
+ * A ticket by itself is a stateless MAC: the gate that signed it cannot say how many a wallet
96
+ * holds, and every game server has to keep its own replay cache to stop one being presented twice.
97
+ * Writing each ticket down moves both jobs here. Issue is rationed per wallet, consume succeeds
98
+ * exactly once, and release is how the server that owns the connection says the seat is free.
99
+ *
100
+ * Scoped by game in every statement, so another game's round reads as no such round.
101
+ */
102
+ export declare class JoinTickets {
103
+ private readonly pool;
104
+ private readonly gameId;
105
+ private readonly maxOpenPerPlayer;
106
+ private readonly maxIssuedPerMinutePerPlayer;
107
+ constructor(pool: Pool, gameId: string, policy?: JoinTicketPolicy);
108
+ /** Sign a ticket and write it down, or refuse a wallet that already holds too many. */
109
+ issue(input: IssueInput): Promise<JoinTicketIssueResult>;
110
+ /**
111
+ * Accept a ticket a browser presented, exactly once.
112
+ *
113
+ * The gate verifies the MAC, the round, the audience and the expiry, so a game server in any
114
+ * language can admit a player with one request and no cryptography of its own.
115
+ */
116
+ consume(input: {
117
+ awardToken: string;
118
+ roundId: string;
119
+ ticket: unknown;
120
+ audience?: unknown;
121
+ }, now?: number): Promise<JoinTicketConsumeResult>;
122
+ /**
123
+ * Give seats back. Releasing an unpresented ticket voids it.
124
+ *
125
+ * Idempotent: a ticket already released is not released again and is not an error, so a server
126
+ * can call this from every code path that ends a connection.
127
+ */
128
+ release(roundId: string, selector: unknown, now?: number): Promise<JoinTicketReleaseResult>;
129
+ /** One wallet's most recent tickets in a round, newest first. Null for a round this game does not own. */
130
+ list(roundId: string, player: string, now?: number): Promise<JoinTicketRecord[] | null>;
131
+ counts(roundId: string, player: string): Promise<JoinTicketCounts>;
132
+ private roundExists;
133
+ }
134
+ export {};
135
+ //# sourceMappingURL=join-ticket-ledger.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-ticket-ledger.d.ts","sourceRoot":"","sources":["../src/join-ticket-ledger.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,190 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { isAddress } from 'viem';
3
+ import { issuePlayerJoinTicket, readPlayerJoinTicket } from './player-join-tickets.js';
4
+ import { inTransaction } from './store.js';
5
+ const IDENTIFIER = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
6
+ const RATE_WINDOW_MS = 60_000;
7
+ const LIST_LIMIT = 100;
8
+ /** Enough for a player whose connection keeps dropping; far too few to farm seats with. */
9
+ export const DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE = 20;
10
+ const COLUMNS = 'jti, round_id, player, audience, issued_at, expires_at, consumed_at, released_at';
11
+ function statusOf(row, now) {
12
+ if (row.released_at !== null)
13
+ return 'released';
14
+ if (row.consumed_at !== null)
15
+ return 'consumed';
16
+ return Number(row.expires_at) > now ? 'issued' : 'expired';
17
+ }
18
+ const record = (row, now) => ({
19
+ jti: row.jti,
20
+ roundId: row.round_id,
21
+ player: row.player,
22
+ audience: row.audience,
23
+ issuedAt: Number(row.issued_at),
24
+ expiresAt: Number(row.expires_at),
25
+ consumedAt: row.consumed_at === null ? null : Number(row.consumed_at),
26
+ releasedAt: row.released_at === null ? null : Number(row.released_at),
27
+ status: statusOf(row, now),
28
+ });
29
+ function positiveInteger(value, name) {
30
+ if (value === undefined)
31
+ return null;
32
+ if (!Number.isSafeInteger(value) || value <= 0)
33
+ throw new RangeError(`${name} must be a positive safe integer`);
34
+ return value;
35
+ }
36
+ /**
37
+ * The gate's record of every join ticket it signs, and what became of each.
38
+ *
39
+ * A ticket by itself is a stateless MAC: the gate that signed it cannot say how many a wallet
40
+ * holds, and every game server has to keep its own replay cache to stop one being presented twice.
41
+ * Writing each ticket down moves both jobs here. Issue is rationed per wallet, consume succeeds
42
+ * exactly once, and release is how the server that owns the connection says the seat is free.
43
+ *
44
+ * Scoped by game in every statement, so another game's round reads as no such round.
45
+ */
46
+ export class JoinTickets {
47
+ pool;
48
+ gameId;
49
+ maxOpenPerPlayer;
50
+ maxIssuedPerMinutePerPlayer;
51
+ constructor(pool, gameId, policy = {}) {
52
+ this.pool = pool;
53
+ this.gameId = gameId;
54
+ this.maxOpenPerPlayer = positiveInteger(policy.maxOpenPerPlayer, 'tickets.maxOpenPerPlayer');
55
+ this.maxIssuedPerMinutePerPlayer =
56
+ positiveInteger(policy.maxIssuedPerMinutePerPlayer, 'tickets.maxIssuedPerMinutePerPlayer') ??
57
+ DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE;
58
+ }
59
+ /** Sign a ticket and write it down, or refuse a wallet that already holds too many. */
60
+ async issue(input) {
61
+ const player = input.player.toLowerCase();
62
+ const jti = randomUUID();
63
+ // Signed before the transaction: it is pure, and its bounds checks should fail before a lock.
64
+ const ticket = issuePlayerJoinTicket({ ...input, player, gameId: this.gameId, jti });
65
+ return inTransaction(this.pool, async (tx) => {
66
+ // Two requests from one wallet must not both read "under the limit" and both insert.
67
+ await lockPlayer(tx, input.roundId, player);
68
+ const counts = await tx.query(`select count(*) filter (where issued_at > $3)::text as recent,
69
+ count(*) filter (
70
+ where released_at is null and (consumed_at is not null or expires_at > $4)
71
+ )::text as open
72
+ from join_tickets where round_id = $1 and player = $2`, [input.roundId, player, input.now - RATE_WINDOW_MS, input.now]);
73
+ const { recent, open } = counts.rows[0];
74
+ if (Number(recent) >= this.maxIssuedPerMinutePerPlayer)
75
+ return { ok: false, refuse: 'ticket.rate_limited' };
76
+ if (this.maxOpenPerPlayer !== null && Number(open) >= this.maxOpenPerPlayer) {
77
+ return { ok: false, refuse: 'ticket.seat_open' };
78
+ }
79
+ await tx.query(`insert into join_tickets (jti, round_id, player, audience, issued_at, expires_at)
80
+ values ($1, $2, $3, $4, $5, $6)`, [jti, input.roundId, player, input.audience, input.now, ticket.expiresAt]);
81
+ return { ok: true, ticket };
82
+ });
83
+ }
84
+ /**
85
+ * Accept a ticket a browser presented, exactly once.
86
+ *
87
+ * The gate verifies the MAC, the round, the audience and the expiry, so a game server in any
88
+ * language can admit a player with one request and no cryptography of its own.
89
+ */
90
+ async consume(input, now = Date.now()) {
91
+ if (typeof input.roundId !== 'string' || !IDENTIFIER.test(input.roundId)) {
92
+ return { ok: false, refuse: 'ticket.invalid' };
93
+ }
94
+ if (input.audience !== undefined && typeof input.audience !== 'string') {
95
+ return { ok: false, refuse: 'ticket.invalid' };
96
+ }
97
+ const claims = readPlayerJoinTicket(input.ticket, input.awardToken);
98
+ if (!claims || claims.gameId !== this.gameId)
99
+ return { ok: false, refuse: 'ticket.invalid' };
100
+ if (!(await this.roundExists(input.roundId)))
101
+ return { ok: false, refuse: 'ticket.no_such_round' };
102
+ if (claims.roundId !== input.roundId)
103
+ return { ok: false, refuse: 'ticket.invalid' };
104
+ // A ticket minted for one server must not seat a player on another that shares the gate.
105
+ if (input.audience !== undefined && input.audience !== claims.audience) {
106
+ return { ok: false, refuse: 'ticket.wrong_audience' };
107
+ }
108
+ const updated = await this.pool.query(`update join_tickets set consumed_at = $3
109
+ where jti = $1 and round_id = $2 and consumed_at is null and released_at is null and expires_at > $3
110
+ returning ${COLUMNS}`, [claims.jti, input.roundId, now]);
111
+ const row = updated.rows[0];
112
+ if (!row) {
113
+ const existing = await this.pool.query(`select ${COLUMNS} from join_tickets where jti = $1 and round_id = $2`, [claims.jti, input.roundId]);
114
+ const found = existing.rows[0];
115
+ // A valid MAC with no row was signed by something other than this ledger.
116
+ if (!found)
117
+ return { ok: false, refuse: 'ticket.invalid' };
118
+ if (found.released_at !== null)
119
+ return { ok: false, refuse: 'ticket.released' };
120
+ if (found.consumed_at !== null)
121
+ return { ok: false, refuse: 'ticket.already_consumed' };
122
+ return { ok: false, refuse: 'ticket.expired' };
123
+ }
124
+ const others = await this.pool.query(`select jti from join_tickets
125
+ where round_id = $1 and player = $2 and jti <> $3 and consumed_at is not null and released_at is null
126
+ order by consumed_at, jti`, [input.roundId, row.player, row.jti]);
127
+ return { ok: true, ticket: record(row, now), otherOpen: others.rows.map((other) => other.jti) };
128
+ }
129
+ /**
130
+ * Give seats back. Releasing an unpresented ticket voids it.
131
+ *
132
+ * Idempotent: a ticket already released is not released again and is not an error, so a server
133
+ * can call this from every code path that ends a connection.
134
+ */
135
+ async release(roundId, selector, now = Date.now()) {
136
+ const parsed = parseSelector(selector);
137
+ if (typeof roundId !== 'string' || !IDENTIFIER.test(roundId) || !parsed) {
138
+ return { ok: false, refuse: 'ticket.invalid' };
139
+ }
140
+ if (!(await this.roundExists(roundId)))
141
+ return { ok: false, refuse: 'ticket.no_such_round' };
142
+ const [filter, value] = 'jti' in parsed
143
+ ? ['and jti = $3', parsed.jti]
144
+ : 'player' in parsed
145
+ ? ['and player = $3', parsed.player.toLowerCase()]
146
+ : ['', null];
147
+ const released = await this.pool.query(`update join_tickets set released_at = $2
148
+ where round_id = $1 and released_at is null ${filter}
149
+ returning jti`, value === null ? [roundId, now] : [roundId, now, value]);
150
+ return { ok: true, released: released.rows.map((row) => row.jti) };
151
+ }
152
+ /** One wallet's most recent tickets in a round, newest first. Null for a round this game does not own. */
153
+ async list(roundId, player, now = Date.now()) {
154
+ if (!(await this.roundExists(roundId)))
155
+ return null;
156
+ const rows = await this.pool.query(`select ${COLUMNS} from join_tickets
157
+ where round_id = $1 and player = $2
158
+ order by issued_at desc, jti
159
+ limit ${LIST_LIMIT}`, [roundId, player.toLowerCase()]);
160
+ return rows.rows.map((row) => record(row, now));
161
+ }
162
+ async counts(roundId, player) {
163
+ const result = await this.pool.query(`select count(*)::text as issued,
164
+ count(*) filter (where consumed_at is not null and released_at is null)::text as open
165
+ from join_tickets where round_id = $1 and player = $2`, [roundId, player.toLowerCase()]);
166
+ return { issued: Number(result.rows[0].issued), open: Number(result.rows[0].open) };
167
+ }
168
+ async roundExists(roundId) {
169
+ const round = await this.pool.query(`select 1 from rounds where id = $1 and game_id = $2 and score_authority = 'external-server'`, [roundId, this.gameId]);
170
+ return round.rows.length > 0;
171
+ }
172
+ }
173
+ function lockPlayer(tx, roundId, player) {
174
+ return tx.query(`select pg_advisory_xact_lock(hashtextextended($1, 0))`, [`gamemode_join_ticket:${roundId}:${player}`]);
175
+ }
176
+ function parseSelector(value) {
177
+ if (typeof value !== 'object' || value === null)
178
+ return null;
179
+ const { jti, player, all } = value;
180
+ // Exactly one, so a body that names a ticket and also says "all" cannot empty a round by accident.
181
+ if ([jti, player, all].filter((entry) => entry !== undefined).length !== 1)
182
+ return null;
183
+ if (jti !== undefined)
184
+ return typeof jti === 'string' && IDENTIFIER.test(jti) ? { jti } : null;
185
+ if (player !== undefined) {
186
+ return typeof player === 'string' && isAddress(player, { strict: false }) ? { player } : null;
187
+ }
188
+ return all === true ? { all: true } : null;
189
+ }
190
+ //# sourceMappingURL=join-ticket-ledger.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"join-ticket-ledger.js","sourceRoot":"","sources":["../src/join-ticket-ledger.ts"],"names":[],"mappings":"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"}
package/dist/main.d.ts CHANGED
@@ -4,6 +4,7 @@ import type { FastifyInstance } from 'fastify';
4
4
  import type { GameModule } from '@flayerlabs/gamemode-spec';
5
5
  import { type PriceFor } from './server.js';
6
6
  import { type GateDeployment } from './chain/chains.js';
7
+ import { type TurnstileConfig } from './turnstile.js';
7
8
  /**
8
9
  * A production gate from an environment, assembled and checked.
9
10
  *
@@ -53,6 +54,12 @@ export interface ResolvedGateConfig {
53
54
  pairedTokenCapHeadroomPercent: number;
54
55
  /** May lift the gate early or rotate the signer on-chain. Defaults to the signing key's address. */
55
56
  settler: `0x${string}` | null;
57
+ /** Reverse proxies between the internet and this process; decides whose address a request has. */
58
+ trustedProxyHops: number;
59
+ /** The Turnstile widget that guards sign-in, or null when sign-in needs no evidence. */
60
+ turnstile: TurnstileConfig | null;
61
+ /** Sign-ins one address may make in a minute. Zero is no limit. */
62
+ sessionsPerIpPerMinute: number;
56
63
  }
57
64
  export interface SpendTokenPricing {
58
65
  address: `0x${string}`;
@@ -94,6 +101,15 @@ export interface SpendTokenPricing {
94
101
  * would otherwise quote; tokens neither here nor approved on the
95
102
  * manager's PairedTokenRegistry are refused.
96
103
  * PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
104
+ * TRUSTED_PROXY_HOPS default 0; reverse proxies in front of the gate (2 on Railway, 3 with
105
+ * a CDN in front of that). Decides the address admission policy sees.
106
+ * SESSIONS_PER_IP_PER_MINUTE sign-ins one address may make in a minute. Default 30 when
107
+ * TRUSTED_PROXY_HOPS is set, otherwise 0 (no limit): until the gate is
108
+ * told its proxies it cannot tell one player's address from another's.
109
+ * TURNSTILE_SITE_KEY optional; with TURNSTILE_SECRET, sign-in requires a Turnstile token
110
+ * TURNSTILE_SECRET optional; set together with TURNSTILE_SITE_KEY or not at all
111
+ * TURNSTILE_ACTION default game-session
112
+ * TURNSTILE_HOSTNAMES default the host of SIGN_IN_DOMAIN; comma-separated
97
113
  */
98
114
  export declare function resolveGateEnv(env: Env): ResolvedGateConfig;
99
115
  export interface StartedGate {
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,EAAiD,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;AACxF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAA+D,KAAK,QAAQ,EAAE,MAAM,aAAa,CAAC;AASzG,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAIvE;;;;;;;;;;;;GAYG;AAEH,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAE9C,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,KAAK,MAAM,EAAE,CAAC;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,yFAAyF;IACzF,YAAY,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,cAAc,CAAC;IAC3B,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IACpD;;;;OAIG;IACH,6BAA6B,EAAE,MAAM,CAAC;IACtC,oGAAoG;IACpG,OAAO,EAAE,KAAK,MAAM,EAAE,GAAG,IAAI,CAAC;CAC/B;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,KAAK,MAAM,EAAE,CAAC;IACvB,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;CACnB;AAuED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAiCG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,kBAAkB,CAmF3D;AA8BD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,eAAe,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,MAAM,EAAE,KAAK,MAAM,EAAE,CAAC;IACtB,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,qEAAqE;IACrE,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,QAAQ,CAAC;CAC1B;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAClF,IAAI,EAAE,UAAU,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,CAAC,EACtE,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,kBAAuB,GACjC,OAAO,CAAC,WAAW,CAAC,CA0ItB"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,MAAM,IAAI,CAAC;AACpB,OAAO,EAAiD,KAAK,YAAY,EAAE,MAAM,MAAM,CAAC;AACxF,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,SAAS,CAAC;AAC/C,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,2BAA2B,CAAC;AAE5D,OAAO,EAKL,KAAK,QAAQ,EACd,MAAM,aAAa,CAAC;AASrB,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGvE,OAAO,EAIL,KAAK,eAAe,EACrB,MAAM,gBAAgB,CAAC;AAGxB;;;;;;;;;;;;GAYG;AAEH,KAAK,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAC;AAE9C,MAAM,WAAW,kBAAkB;IACjC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,CAAC;IACf,gBAAgB,EAAE,KAAK,MAAM,EAAE,CAAC;IAChC,WAAW,EAAE,MAAM,CAAC;IACpB,aAAa,EAAE,MAAM,CAAC;IACtB,yFAAyF;IACzF,YAAY,EAAE,MAAM,CAAC;IACrB,kGAAkG;IAClG,UAAU,EAAE,MAAM,CAAC;IACnB,cAAc,EAAE,CAAC,MAAM,GAAG,MAAM,CAAC,EAAE,CAAC;IACpC,IAAI,EAAE,MAAM,CAAC;IACb,UAAU,EAAE,cAAc,CAAC;IAC3B,iFAAiF;IACjF,SAAS,EAAE,MAAM,CAAC;IAClB,eAAe,EAAE,MAAM,CAAC;IACxB,cAAc,EAAE,MAAM,CAAC;IACvB,gBAAgB,EAAE,MAAM,CAAC;IACzB,eAAe,EAAE,MAAM,CAAC;IACxB,0FAA0F;IAC1F,SAAS,EAAE,MAAM,CAAC;IAClB;;;;OAIG;IACH,WAAW,EAAE,WAAW,CAAC,MAAM,EAAE,iBAAiB,CAAC,CAAC;IACpD;;;;OAIG;IACH,6BAA6B,EAAE,MAAM,CAAC;IACtC,oGAAoG;IACpG,OAAO,EAAE,KAAK,MAAM,EAAE,GAAG,IAAI,CAAC;IAC9B,kGAAkG;IAClG,gBAAgB,EAAE,MAAM,CAAC;IACzB,wFAAwF;IACxF,SAAS,EAAE,eAAe,GAAG,IAAI,CAAC;IAClC,mEAAmE;IACnE,sBAAsB,EAAE,MAAM,CAAC;CAChC;AAED,MAAM,WAAW,iBAAiB;IAChC,OAAO,EAAE,KAAK,MAAM,EAAE,CAAC;IACvB,+BAA+B;IAC/B,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;CACnB;AAuED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA0CG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,kBAAkB,CA4F3D;AA8BD,MAAM,WAAW,WAAW;IAC1B,GAAG,EAAE,eAAe,CAAC;IACrB,IAAI,EAAE,MAAM,CAAC;IACb,0FAA0F;IAC1F,MAAM,EAAE,KAAK,MAAM,EAAE,CAAC;IACtB,IAAI,EAAE,EAAE,CAAC,IAAI,CAAC;CACf;AAED,MAAM,WAAW,kBAAkB;IACjC,GAAG,CAAC,EAAE,GAAG,CAAC;IACV,qEAAqE;IACrE,MAAM,CAAC,EAAE,YAAY,CAAC;IACtB,iEAAiE;IACjE,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB;;;OAGG;IACH,aAAa,CAAC,EAAE,QAAQ,CAAC;CAC1B;AAED,wBAAsB,SAAS,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,EAClF,IAAI,EAAE,UAAU,CAAC,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,EAAE,UAAU,CAAC,EACtE,UAAU,EAAE,MAAM,EAClB,SAAS,GAAE,kBAAuB,GACjC,OAAO,CAAC,WAAW,CAAC,CA2JtB"}
package/dist/main.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createGateReadiness } from './readiness.js';
2
2
  import pg from 'pg';
3
3
  import { createPublicClient, http, isAddress, parseAbi } from 'viem';
4
- import { createGate } from './server.js';
4
+ import { MAX_TRUSTED_PROXY_HOPS, createGate, } from './server.js';
5
5
  import { Ledger } from './ledger.js';
6
6
  import { Claims } from './claims.js';
7
7
  import { Sessions } from './sessions.js';
@@ -13,6 +13,8 @@ import { resolveEconomy } from './economy.js';
13
13
  import { deploymentFor } from './chain/chains.js';
14
14
  import { assertSecureConfig } from './chain/secure.js';
15
15
  import { RegistryPrices } from './chain/registry-prices.js';
16
+ import { isTurnstileTestingSecret, resolveTurnstileEnv, turnstileAdmission, } from './turnstile.js';
17
+ import { composeAdmit, createRateLimitAdmit } from './admission.js';
16
18
  function required(env, name) {
17
19
  const value = env[name];
18
20
  if (!value)
@@ -121,6 +123,15 @@ function address(env, name) {
121
123
  * would otherwise quote; tokens neither here nor approved on the
122
124
  * manager's PairedTokenRegistry are refused.
123
125
  * PAIRED_TOKEN_CAP_HEADROOM_PERCENT default 25; headroom on caps sized for registry-priced pairings
126
+ * TRUSTED_PROXY_HOPS default 0; reverse proxies in front of the gate (2 on Railway, 3 with
127
+ * a CDN in front of that). Decides the address admission policy sees.
128
+ * SESSIONS_PER_IP_PER_MINUTE sign-ins one address may make in a minute. Default 30 when
129
+ * TRUSTED_PROXY_HOPS is set, otherwise 0 (no limit): until the gate is
130
+ * told its proxies it cannot tell one player's address from another's.
131
+ * TURNSTILE_SITE_KEY optional; with TURNSTILE_SECRET, sign-in requires a Turnstile token
132
+ * TURNSTILE_SECRET optional; set together with TURNSTILE_SITE_KEY or not at all
133
+ * TURNSTILE_ACTION default game-session
134
+ * TURNSTILE_HOSTNAMES default the host of SIGN_IN_DOMAIN; comma-separated
124
135
  */
125
136
  export function resolveGateEnv(env) {
126
137
  const chainId = Number(required(env, 'CHAIN_ID'));
@@ -170,13 +181,18 @@ export function resolveGateEnv(env) {
170
181
  : origin);
171
182
  if (allowedOrigins.length === 0)
172
183
  throw new Error('ALLOWED_ORIGINS names no origins');
184
+ const signInDomain = required(env, 'SIGN_IN_DOMAIN');
185
+ const trustedProxyHops = integer(env, 'TRUSTED_PROXY_HOPS', 0);
186
+ if (trustedProxyHops > MAX_TRUSTED_PROXY_HOPS) {
187
+ throw new Error(`TRUSTED_PROXY_HOPS must be at most ${MAX_TRUSTED_PROXY_HOPS}`);
188
+ }
173
189
  return {
174
190
  chainId,
175
191
  rpcUrl: required(env, 'RPC_URL'),
176
192
  signerPrivateKey: signerPrivateKey,
177
193
  databaseUrl: required(env, 'DATABASE_URL'),
178
194
  sessionSecret: required(env, 'SESSION_SECRET'),
179
- signInDomain: required(env, 'SIGN_IN_DOMAIN'),
195
+ signInDomain,
180
196
  gateOrigin: required(env, 'GATE_ORIGIN'),
181
197
  allowedOrigins,
182
198
  port: integer(env, 'PORT', 8790),
@@ -196,6 +212,9 @@ export function resolveGateEnv(env) {
196
212
  spendTokens: spendTokens(env),
197
213
  pairedTokenCapHeadroomPercent: integer(env, 'PAIRED_TOKEN_CAP_HEADROOM_PERCENT', 25),
198
214
  settler: address(env, 'SETTLER'),
215
+ trustedProxyHops,
216
+ turnstile: resolveTurnstileEnv(env, signInDomain),
217
+ sessionsPerIpPerMinute: integer(env, 'SESSIONS_PER_IP_PER_MINUTE', trustedProxyHops > 0 ? 30 : 0),
199
218
  };
200
219
  }
201
220
  const erc20 = parseAbi(['function symbol() view returns (string)', 'function decimals() view returns (uint8)']);
@@ -296,6 +315,18 @@ export async function startGate(game, gameConfig, overrides = {}) {
296
315
  const ledger = new Ledger(pool);
297
316
  const signer = new PayloadSigner(config.signerPrivateKey, config.chainId, config.deployment.spendGatedCalculator);
298
317
  const settler = config.settler ?? signer.address;
318
+ const turnstile = config.turnstile ? turnstileAdmission(config.turnstile) : null;
319
+ if (config.turnstile && isTurnstileTestingSecret(config.turnstile.secret)) {
320
+ // Loud because it is silent otherwise: the widget renders and every caller passes.
321
+ console.warn('TURNSTILE_SECRET is a Cloudflare testing secret: sign-in is not protected');
322
+ }
323
+ // The rate limit first: a caller over budget is refused before a Turnstile token is spent on them.
324
+ const rateLimit = config.sessionsPerIpPerMinute > 0
325
+ ? createRateLimitAdmit({
326
+ limits: { session: { perIp: { max: config.sessionsPerIpPerMinute, windowMs: 60_000 } } },
327
+ })
328
+ : undefined;
329
+ const admit = rateLimit || turnstile ? composeAdmit(rateLimit, turnstile?.admit) : undefined;
299
330
  const announce = () => ({
300
331
  chainId: config.chainId,
301
332
  contracts: {
@@ -317,6 +348,7 @@ export async function startGate(game, gameConfig, overrides = {}) {
317
348
  accepting: true,
318
349
  publicLaunchesOpen: true,
319
350
  privateLaunches: false,
351
+ ...(turnstile ? { admission: turnstile.admission } : {}),
320
352
  });
321
353
  const app = createGate({
322
354
  pool,
@@ -345,6 +377,8 @@ export async function startGate(game, gameConfig, overrides = {}) {
345
377
  }),
346
378
  settlement: new Settlement(pool, ledger, client, config.deployment.spendGatedCalculator),
347
379
  allowedOrigins: config.allowedOrigins,
380
+ trustedProxyHops: config.trustedProxyHops,
381
+ ...(admit ? { admit } : {}),
348
382
  announce,
349
383
  readiness: createGateReadiness({ origin: config.gateOrigin, chainId: config.chainId,
350
384
  positionManager: config.deployment.positionManager, signer, pool, client }),