@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.
- package/DEPLOY.md +6 -1
- package/README.md +4 -2
- package/dist/admission.d.ts +107 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +201 -0
- package/dist/admission.js.map +1 -0
- package/dist/chain/secure.d.ts +17 -0
- package/dist/chain/secure.d.ts.map +1 -1
- package/dist/chain/secure.js +40 -0
- package/dist/chain/secure.js.map +1 -1
- package/dist/index.d.ts +10 -4
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/ip-blocks.d.ts +50 -0
- package/dist/ip-blocks.d.ts.map +1 -0
- package/dist/ip-blocks.js +125 -0
- package/dist/ip-blocks.js.map +1 -0
- package/dist/join-ticket-ledger.d.ts +135 -0
- package/dist/join-ticket-ledger.d.ts.map +1 -0
- package/dist/join-ticket-ledger.js +190 -0
- package/dist/join-ticket-ledger.js.map +1 -0
- package/dist/main.d.ts +16 -0
- package/dist/main.d.ts.map +1 -1
- package/dist/main.js +36 -2
- package/dist/main.js.map +1 -1
- package/dist/player-join-tickets.d.ts +10 -0
- package/dist/player-join-tickets.d.ts.map +1 -1
- package/dist/player-join-tickets.js +27 -14
- package/dist/player-join-tickets.js.map +1 -1
- package/dist/server.d.ts +41 -3
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +145 -11
- package/dist/server.js.map +1 -1
- package/dist/store.d.ts +1 -1
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +25 -0
- package/dist/store.js.map +1 -1
- package/dist/turnstile.d.ts +37 -1
- package/dist/turnstile.d.ts.map +1 -1
- package/dist/turnstile.js +82 -2
- package/dist/turnstile.js.map +1 -1
- package/dist/version.js +2 -2
- package/package.json +3 -3
- package/src/admission.ts +273 -0
- package/src/chain/secure.ts +52 -0
- package/src/index.ts +34 -2
- package/src/ip-blocks.ts +154 -0
- package/src/join-ticket-ledger.ts +325 -0
- package/src/main.ts +56 -2
- package/src/player-join-tickets.ts +34 -19
- package/src/server.ts +186 -14
- package/src/store.ts +25 -0
- package/src/turnstile.ts +96 -2
- 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 {
|
package/dist/main.d.ts.map
CHANGED
|
@@ -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,
|
|
1
|
+
{"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":"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"}
|
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
|
|
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 }),
|