@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
package/src/admission.ts
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import type { Admission, AdmissionRequest, Admit } from './server.js';
|
|
2
|
+
|
|
3
|
+
type Checkpoint = AdmissionRequest['at'];
|
|
4
|
+
|
|
5
|
+
const TOO_MANY: Admission = { ok: false, reason: 'too many tries, wait a minute' };
|
|
6
|
+
const SUSPICIOUS: Admission = { ok: false, reason: 'you cannot play from this network' };
|
|
7
|
+
|
|
8
|
+
/** IPv4 arriving over a dual-stack socket is the same caller as the bare IPv4 address. */
|
|
9
|
+
export function normaliseIp(ip: string): string {
|
|
10
|
+
const mapped = /^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/i.exec(ip);
|
|
11
|
+
return (mapped ? mapped[1]! : ip).toLowerCase();
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Run several admission policies as one. The first refusal is the answer.
|
|
16
|
+
*
|
|
17
|
+
* In order, so put the cheap local checks first: a rate limit that refuses saves the Turnstile
|
|
18
|
+
* round trip behind it, and a spent Turnstile token is not wasted on a caller already over budget.
|
|
19
|
+
*/
|
|
20
|
+
export function composeAdmit(...policies: readonly (Admit | undefined)[]): Admit {
|
|
21
|
+
const active = policies.filter((policy): policy is Admit => policy !== undefined);
|
|
22
|
+
return async (request) => {
|
|
23
|
+
for (const policy of active) {
|
|
24
|
+
const admission = await policy(request);
|
|
25
|
+
if (!admission.ok) return admission;
|
|
26
|
+
}
|
|
27
|
+
return { ok: true };
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** At most `max` requests in any `windowMs`, approximately: two adjacent windows are weighed. */
|
|
32
|
+
export interface Rate {
|
|
33
|
+
max: number;
|
|
34
|
+
windowMs: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface CheckpointLimits {
|
|
38
|
+
/** Skipped when the caller's address is unknown. */
|
|
39
|
+
perIp?: Rate;
|
|
40
|
+
/** Skipped at `session`, where nobody has proved who they are yet. */
|
|
41
|
+
perPlayer?: Rate;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type RateLimits = Partial<Record<Checkpoint, CheckpointLimits>>;
|
|
45
|
+
|
|
46
|
+
export interface RateLimitAdmitOptions {
|
|
47
|
+
limits: RateLimits;
|
|
48
|
+
/** Most distinct callers remembered at once. Bounds memory under rotating addresses. */
|
|
49
|
+
maxKeys?: number;
|
|
50
|
+
/** Test seam. */
|
|
51
|
+
now?: () => number;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Budgets generous enough for a household or an office behind one address, and far too small to
|
|
56
|
+
* farm sessions or tickets with. Gameplay and claims have no default: only the game knows its
|
|
57
|
+
* cadence, and a false refusal on a claim costs a player a purchase.
|
|
58
|
+
*/
|
|
59
|
+
export const DEFAULT_RATE_LIMITS: RateLimits = {
|
|
60
|
+
session: { perIp: { max: 30, windowMs: 60_000 } },
|
|
61
|
+
ticket: { perIp: { max: 60, windowMs: 60_000 } },
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const DEFAULT_MAX_KEYS = 100_000;
|
|
65
|
+
|
|
66
|
+
interface Bucket {
|
|
67
|
+
/** The start of the current fixed window. */
|
|
68
|
+
start: number;
|
|
69
|
+
count: number;
|
|
70
|
+
/** What the previous window ended with, weighed in by how much of it still overlaps. */
|
|
71
|
+
previous: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function assertRate(rate: Rate | undefined, name: string): void {
|
|
75
|
+
if (rate === undefined) return;
|
|
76
|
+
if (!Number.isSafeInteger(rate.max) || rate.max <= 0) throw new RangeError(`${name}.max must be a positive safe integer`);
|
|
77
|
+
if (!Number.isSafeInteger(rate.windowMs) || rate.windowMs <= 0) {
|
|
78
|
+
throw new RangeError(`${name}.windowMs must be a positive safe integer`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A request budget per address and per wallet, kept in this process.
|
|
84
|
+
*
|
|
85
|
+
* In memory because a gate is one process per game: there is no second replica for a caller to
|
|
86
|
+
* spread across, and a budget that survives a restart is not worth a database write on every
|
|
87
|
+
* action. The budget is spent only by admitted requests, so a caller refused by an earlier policy
|
|
88
|
+
* in {@link composeAdmit} does not burn it.
|
|
89
|
+
*
|
|
90
|
+
* Needs the real caller address. Behind a proxy, set `trustedProxyHops` first: otherwise every
|
|
91
|
+
* player shares the proxy's address and one budget.
|
|
92
|
+
*/
|
|
93
|
+
export function createRateLimitAdmit(options: RateLimitAdmitOptions): Admit {
|
|
94
|
+
for (const [at, limits] of Object.entries(options.limits)) {
|
|
95
|
+
assertRate(limits?.perIp, `limits.${at}.perIp`);
|
|
96
|
+
assertRate(limits?.perPlayer, `limits.${at}.perPlayer`);
|
|
97
|
+
}
|
|
98
|
+
const maxKeys = options.maxKeys ?? DEFAULT_MAX_KEYS;
|
|
99
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys <= 0) throw new RangeError('maxKeys must be a positive safe integer');
|
|
100
|
+
const now = options.now ?? Date.now;
|
|
101
|
+
const buckets = new Map<string, Bucket>();
|
|
102
|
+
|
|
103
|
+
/** Whether one more request fits, without spending it. */
|
|
104
|
+
const fits = (key: string, rate: Rate, at: number): boolean => {
|
|
105
|
+
const bucket = buckets.get(key);
|
|
106
|
+
if (!bucket) return true;
|
|
107
|
+
const elapsed = at - bucket.start;
|
|
108
|
+
if (elapsed >= 2 * rate.windowMs) return true;
|
|
109
|
+
const [current, previous] = elapsed >= rate.windowMs ? [0, bucket.count] : [bucket.count, bucket.previous];
|
|
110
|
+
const into = elapsed % rate.windowMs;
|
|
111
|
+
return current + previous * (1 - into / rate.windowMs) < rate.max;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const spend = (key: string, rate: Rate, at: number): void => {
|
|
115
|
+
const bucket = buckets.get(key);
|
|
116
|
+
const elapsed = bucket ? at - bucket.start : Infinity;
|
|
117
|
+
if (!bucket || elapsed >= 2 * rate.windowMs) {
|
|
118
|
+
buckets.delete(key);
|
|
119
|
+
if (buckets.size >= maxKeys) buckets.delete(buckets.keys().next().value as string);
|
|
120
|
+
buckets.set(key, { start: at, count: 1, previous: 0 });
|
|
121
|
+
} else if (elapsed >= rate.windowMs) {
|
|
122
|
+
bucket.previous = bucket.count;
|
|
123
|
+
bucket.count = 1;
|
|
124
|
+
bucket.start += rate.windowMs;
|
|
125
|
+
} else {
|
|
126
|
+
bucket.count += 1;
|
|
127
|
+
}
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
return (request) => {
|
|
131
|
+
const limits = options.limits[request.at];
|
|
132
|
+
if (!limits) return { ok: true };
|
|
133
|
+
const at = now();
|
|
134
|
+
const checks: [string, Rate][] = [];
|
|
135
|
+
if (limits.perIp && request.ip) checks.push([`${request.at}:ip:${normaliseIp(request.ip)}`, limits.perIp]);
|
|
136
|
+
if (limits.perPlayer && request.player) {
|
|
137
|
+
checks.push([`${request.at}:player:${request.player.toLowerCase()}`, limits.perPlayer]);
|
|
138
|
+
}
|
|
139
|
+
// All budgets are checked before any is spent, so a refusal by one does not drain another.
|
|
140
|
+
if (checks.some(([key, rate]) => !fits(key, rate, at))) return TOO_MANY;
|
|
141
|
+
for (const [key, rate] of checks) spend(key, rate, at);
|
|
142
|
+
return { ok: true };
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** What a reputation provider says about an address. Absent fields mean "not known to be". */
|
|
147
|
+
export interface IpVerdict {
|
|
148
|
+
vpn?: boolean;
|
|
149
|
+
proxy?: boolean;
|
|
150
|
+
tor?: boolean;
|
|
151
|
+
/** A privacy relay such as iCloud Private Relay. Ordinary people use these. */
|
|
152
|
+
relay?: boolean;
|
|
153
|
+
/** A datacentre or cloud address. */
|
|
154
|
+
hosting?: boolean;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export type IpLookup = (ip: string, signal: AbortSignal) => Promise<IpVerdict>;
|
|
158
|
+
|
|
159
|
+
export interface IpReputationAdmitOptions {
|
|
160
|
+
/** Your provider. The gate ships no default: every provider needs an account only you have. */
|
|
161
|
+
lookup: IpLookup;
|
|
162
|
+
/** Which checkpoints consult the provider. Defaults to `session` only. */
|
|
163
|
+
at?: readonly Checkpoint[];
|
|
164
|
+
/**
|
|
165
|
+
* Whether a verdict is a refusal. Defaults to VPN, proxy, Tor or hosting. Relays are admitted by
|
|
166
|
+
* default, because refusing them refuses every Safari user with Private Relay on.
|
|
167
|
+
*/
|
|
168
|
+
refuse?: (verdict: IpVerdict) => boolean;
|
|
169
|
+
/** How long a verdict is reused. Defaults to one hour. */
|
|
170
|
+
cacheMs?: number;
|
|
171
|
+
/** Defaults to 2,000. */
|
|
172
|
+
timeoutMs?: number;
|
|
173
|
+
/** Most addresses remembered at once. Defaults to 50,000. */
|
|
174
|
+
maxCached?: number;
|
|
175
|
+
/**
|
|
176
|
+
* What to do when the provider fails or times out. Defaults to admitting: an outage at a third
|
|
177
|
+
* party should not close your game. Set true only if a lockout is cheaper for you than a bot.
|
|
178
|
+
*/
|
|
179
|
+
failClosed?: boolean;
|
|
180
|
+
/** Test seam. */
|
|
181
|
+
now?: () => number;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const defaultRefuse = (verdict: IpVerdict): boolean =>
|
|
185
|
+
verdict.vpn === true || verdict.proxy === true || verdict.tor === true || verdict.hosting === true;
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Refuse callers whose address a reputation provider calls anonymising.
|
|
189
|
+
*
|
|
190
|
+
* A seam, not a detector. Proxy and VPN detection is a paid data product with false positives, and
|
|
191
|
+
* which of them a game can live with is the operator's call. Whatever the provider, one lookup is
|
|
192
|
+
* made per address per `cacheMs`, and concurrent requests from one address share it.
|
|
193
|
+
*/
|
|
194
|
+
export function createIpReputationAdmit(options: IpReputationAdmitOptions): Admit {
|
|
195
|
+
const checkpoints = new Set<Checkpoint>(options.at ?? ['session']);
|
|
196
|
+
const refuse = options.refuse ?? defaultRefuse;
|
|
197
|
+
const cacheMs = options.cacheMs ?? 60 * 60_000;
|
|
198
|
+
const timeoutMs = options.timeoutMs ?? 2_000;
|
|
199
|
+
const maxCached = options.maxCached ?? 50_000;
|
|
200
|
+
for (const [name, value] of [['cacheMs', cacheMs], ['timeoutMs', timeoutMs], ['maxCached', maxCached]] as const) {
|
|
201
|
+
if (!Number.isSafeInteger(value) || value <= 0) throw new RangeError(`${name} must be a positive safe integer`);
|
|
202
|
+
}
|
|
203
|
+
const now = options.now ?? Date.now;
|
|
204
|
+
const cache = new Map<string, { refused: boolean; until: number }>();
|
|
205
|
+
const inFlight = new Map<string, Promise<boolean | null>>();
|
|
206
|
+
|
|
207
|
+
const ask = async (ip: string): Promise<boolean | null> => {
|
|
208
|
+
try {
|
|
209
|
+
const refused = refuse(await options.lookup(ip, AbortSignal.timeout(timeoutMs)));
|
|
210
|
+
cache.delete(ip);
|
|
211
|
+
if (cache.size >= maxCached) cache.delete(cache.keys().next().value as string);
|
|
212
|
+
cache.set(ip, { refused, until: now() + cacheMs });
|
|
213
|
+
return refused;
|
|
214
|
+
} catch {
|
|
215
|
+
// Not cached: the next request asks again rather than inheriting an outage.
|
|
216
|
+
return null;
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
return async (request) => {
|
|
221
|
+
if (!checkpoints.has(request.at) || !request.ip) return { ok: true };
|
|
222
|
+
const ip = normaliseIp(request.ip);
|
|
223
|
+
const cached = cache.get(ip);
|
|
224
|
+
let refused: boolean | null;
|
|
225
|
+
if (cached && cached.until > now()) {
|
|
226
|
+
refused = cached.refused;
|
|
227
|
+
} else {
|
|
228
|
+
let pending = inFlight.get(ip);
|
|
229
|
+
if (!pending) {
|
|
230
|
+
pending = ask(ip).finally(() => inFlight.delete(ip));
|
|
231
|
+
inFlight.set(ip, pending);
|
|
232
|
+
}
|
|
233
|
+
refused = await pending;
|
|
234
|
+
}
|
|
235
|
+
if (refused === null) return options.failClosed ? SUSPICIOUS : { ok: true };
|
|
236
|
+
return refused ? SUSPICIOUS : { ok: true };
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export interface IpinfoPrivacyOptions {
|
|
241
|
+
/** An IPinfo token on a plan that includes privacy detection. */
|
|
242
|
+
token: string;
|
|
243
|
+
/** Test seam. */
|
|
244
|
+
fetch?: typeof globalThis.fetch;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* A reference {@link IpLookup} for IPinfo's privacy detection endpoint.
|
|
249
|
+
*
|
|
250
|
+
* Here to show the shape of an adapter, and because it is one HTTP call. Check the response
|
|
251
|
+
* against your own IPinfo plan before relying on it: this library has no IPinfo account and does
|
|
252
|
+
* not test against the live service.
|
|
253
|
+
*/
|
|
254
|
+
export function ipinfoPrivacyLookup(options: IpinfoPrivacyOptions): IpLookup {
|
|
255
|
+
if (!options.token) throw new RangeError('IPinfo token is required');
|
|
256
|
+
const request = options.fetch ?? globalThis.fetch;
|
|
257
|
+
return async (ip, signal) => {
|
|
258
|
+
const response = await request(`https://ipinfo.io/${encodeURIComponent(ip)}/privacy`, {
|
|
259
|
+
headers: { authorization: `Bearer ${options.token}`, accept: 'application/json' },
|
|
260
|
+
signal,
|
|
261
|
+
});
|
|
262
|
+
if (!response.ok) throw new Error(`IPinfo answered ${response.status}`);
|
|
263
|
+
const body = (await response.json()) as Record<string, unknown>;
|
|
264
|
+
if (typeof body !== 'object' || body === null) throw new Error('IPinfo answered with something other than an object');
|
|
265
|
+
return {
|
|
266
|
+
vpn: body.vpn === true,
|
|
267
|
+
proxy: body.proxy === true,
|
|
268
|
+
tor: body.tor === true,
|
|
269
|
+
relay: body.relay === true,
|
|
270
|
+
hosting: body.hosting === true,
|
|
271
|
+
};
|
|
272
|
+
};
|
|
273
|
+
}
|
package/src/chain/secure.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { keccak256, parseAbi, toHex, type PublicClient } from 'viem';
|
|
2
|
+
import { spendAuthorisationTypedData } from '@flayerlabs/gamemode-spec/embed';
|
|
2
3
|
import type { FlaunchVariant } from './chains.js';
|
|
3
4
|
|
|
4
5
|
/**
|
|
@@ -25,8 +26,56 @@ const wiringAbi = parseAbi([
|
|
|
25
26
|
'function registeredCalculators(address) view returns (bool)',
|
|
26
27
|
'function hasRole(bytes32 role, address account) view returns (bool)',
|
|
27
28
|
'function approvedRouters(address) view returns (bool)',
|
|
29
|
+
'function eip712Domain() view returns (bytes1 fields, string name, string version, uint256 chainId, address verifyingContract, bytes32 salt, uint256[] extensions)',
|
|
28
30
|
]);
|
|
29
31
|
|
|
32
|
+
/**
|
|
33
|
+
* The EIP-712 domain this gate signs under, read off the typed-data builder so the two can never
|
|
34
|
+
* disagree. A calculator whose on-chain domain differs (the version-1 generation, say) rejects every
|
|
35
|
+
* authorisation this gate mints as `InvalidSigner` — at claim time, in a round that will not come
|
|
36
|
+
* back — so boot refuses it here instead.
|
|
37
|
+
*/
|
|
38
|
+
const SIGNING_DOMAIN = spendAuthorisationTypedData(
|
|
39
|
+
{ buyer: `0x${'00'.repeat(20)}`, poolId: `0x${'00'.repeat(32)}`, deadline: 0n, spendCeilingWei: 0n },
|
|
40
|
+
1,
|
|
41
|
+
`0x${'00'.repeat(20)}`,
|
|
42
|
+
).domain;
|
|
43
|
+
|
|
44
|
+
/** What the calculator says its own EIP-712 domain is, or why it could not be read. */
|
|
45
|
+
export type CalculatorDomain = { ok: true; name: string; version: string } | { ok: false; reason: string };
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Does this calculator verify signatures under the domain this gate signs?
|
|
49
|
+
*
|
|
50
|
+
* Reads `eip712Domain()` (EIP-5267) and compares name and version with the typed data the gate's
|
|
51
|
+
* signer produces. Same shape as {@link checkPositionManagerWiring}: a verdict with a reason the
|
|
52
|
+
* operator can act on, because the fix is one env var (`SPEND_GATED_CALCULATOR`), not a redeploy.
|
|
53
|
+
*/
|
|
54
|
+
export async function checkCalculatorDomain(
|
|
55
|
+
client: Pick<PublicClient, 'readContract'>,
|
|
56
|
+
calculator: `0x${string}`,
|
|
57
|
+
): Promise<CalculatorDomain> {
|
|
58
|
+
let name: string;
|
|
59
|
+
let version: string;
|
|
60
|
+
try {
|
|
61
|
+
const domain = (await client.readContract({ address: calculator, abi: wiringAbi, functionName: 'eip712Domain' })) as readonly unknown[];
|
|
62
|
+
name = String(domain[1]);
|
|
63
|
+
version = String(domain[2]);
|
|
64
|
+
} catch {
|
|
65
|
+
return { ok: false, reason: `${calculator} does not answer eip712Domain() — is it a SpendGatedSignerFeeCalculator?` };
|
|
66
|
+
}
|
|
67
|
+
if (name !== SIGNING_DOMAIN.name || version !== SIGNING_DOMAIN.version) {
|
|
68
|
+
return {
|
|
69
|
+
ok: false,
|
|
70
|
+
reason:
|
|
71
|
+
`SPEND_GATED_CALCULATOR ${calculator} verifies "${name}" version ${version}, but this gate signs ` +
|
|
72
|
+
`"${SIGNING_DOMAIN.name}" version ${SIGNING_DOMAIN.version}; every authorisation it minted would be refused as ` +
|
|
73
|
+
'InvalidSigner. Point SPEND_GATED_CALCULATOR at the calculator generation this SDK pairs with',
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
return { ok: true, name, version };
|
|
77
|
+
}
|
|
78
|
+
|
|
30
79
|
export interface SecureConfig {
|
|
31
80
|
chainId: number;
|
|
32
81
|
signerPrivateKey: string;
|
|
@@ -65,6 +114,9 @@ export async function assertSecureConfig(client: PublicClient, config: SecureCon
|
|
|
65
114
|
throw new Error(`SPEND_GATED_CALCULATOR ${config.spendGatedCalculator} holds no code on chain ${chainId}`);
|
|
66
115
|
}
|
|
67
116
|
|
|
117
|
+
const domain = await checkCalculatorDomain(client, config.spendGatedCalculator);
|
|
118
|
+
if (!domain.ok) throw new Error(domain.reason);
|
|
119
|
+
|
|
68
120
|
if (config.positionManager) await assertWiring(client, config);
|
|
69
121
|
}
|
|
70
122
|
|
package/src/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export {
|
|
3
3
|
Claims,
|
|
4
4
|
DEFAULT_SETTLEMENT_TAIL_MS,
|
|
5
|
+
MAX_TRUSTED_PROXY_HOPS,
|
|
5
6
|
Room,
|
|
6
7
|
Sessions,
|
|
7
8
|
createGate,
|
|
@@ -9,6 +10,7 @@ export {
|
|
|
9
10
|
} from './server.js';
|
|
10
11
|
export type {
|
|
11
12
|
Admission,
|
|
13
|
+
AdmissionAnnouncement,
|
|
12
14
|
AdmissionRequest,
|
|
13
15
|
Admit,
|
|
14
16
|
AnnouncedSpendToken,
|
|
@@ -19,8 +21,27 @@ export type {
|
|
|
19
21
|
PriceFor,
|
|
20
22
|
VerifiedLaunch,
|
|
21
23
|
} from './server.js';
|
|
22
|
-
export {
|
|
23
|
-
|
|
24
|
+
export {
|
|
25
|
+
DEFAULT_RATE_LIMITS,
|
|
26
|
+
composeAdmit,
|
|
27
|
+
createIpReputationAdmit,
|
|
28
|
+
createRateLimitAdmit,
|
|
29
|
+
ipinfoPrivacyLookup,
|
|
30
|
+
} from './admission.js';
|
|
31
|
+
export type {
|
|
32
|
+
CheckpointLimits,
|
|
33
|
+
IpLookup,
|
|
34
|
+
IpReputationAdmitOptions,
|
|
35
|
+
IpVerdict,
|
|
36
|
+
IpinfoPrivacyOptions,
|
|
37
|
+
Rate,
|
|
38
|
+
RateLimitAdmitOptions,
|
|
39
|
+
RateLimits,
|
|
40
|
+
} from './admission.js';
|
|
41
|
+
export { IpBlocks, MAX_IP_BLOCKS } from './ip-blocks.js';
|
|
42
|
+
export type { IpBlock, IpBlockRefusal, IpBlockResult } from './ip-blocks.js';
|
|
43
|
+
export { createTurnstileAdmit, isTurnstileTestingSecret, resolveTurnstileEnv, turnstileAdmission } from './turnstile.js';
|
|
44
|
+
export type { TurnstileAdmitOptions, TurnstileConfig } from './turnstile.js';
|
|
24
45
|
|
|
25
46
|
export { Ledger } from './ledger.js';
|
|
26
47
|
export type { Authorisation, Quote } from './ledger.js';
|
|
@@ -57,6 +78,17 @@ export type {
|
|
|
57
78
|
GameServerPlayerStanding,
|
|
58
79
|
} from './game-server-awards.js';
|
|
59
80
|
export { PLAYER_JOIN_TICKET_TTL_MS, verifyPlayerJoinTicket } from './player-join-tickets.js';
|
|
81
|
+
export { DEFAULT_MAX_JOIN_TICKETS_PER_MINUTE } from './join-ticket-ledger.js';
|
|
82
|
+
export type {
|
|
83
|
+
JoinTicketConsumeRefusal,
|
|
84
|
+
JoinTicketConsumption,
|
|
85
|
+
JoinTicketCounts,
|
|
86
|
+
JoinTicketIssueRefusal,
|
|
87
|
+
JoinTicketPolicy,
|
|
88
|
+
JoinTicketRecord,
|
|
89
|
+
JoinTicketReleaseSelector,
|
|
90
|
+
JoinTicketStatus,
|
|
91
|
+
} from './join-ticket-ledger.js';
|
|
60
92
|
export type { VerifiedPlayerJoin, VerifyPlayerJoinTicketOptions } from './player-join-tickets.js';
|
|
61
93
|
|
|
62
94
|
/**
|
package/src/ip-blocks.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { BlockList, isIP } from 'node:net';
|
|
2
|
+
import type { Pool } from 'pg';
|
|
3
|
+
import { normaliseIp } from './admission.js';
|
|
4
|
+
|
|
5
|
+
/** A list longer than this is a job for a firewall in front of the gate, not a table inside it. */
|
|
6
|
+
export const MAX_IP_BLOCKS = 10_000;
|
|
7
|
+
const MAX_REASON_LENGTH = 200;
|
|
8
|
+
const DEFAULT_REFRESH_MS = 30_000;
|
|
9
|
+
|
|
10
|
+
export interface IpBlock {
|
|
11
|
+
/** Canonical network, such as `203.0.113.0/24` or `2001:db8::/32`. A single address is a /32 or /128. */
|
|
12
|
+
cidr: string;
|
|
13
|
+
/** An operator's note. Never shown to the player. */
|
|
14
|
+
reason: string | null;
|
|
15
|
+
createdAt: number;
|
|
16
|
+
/** Milliseconds, or null for a block that stays until it is removed. */
|
|
17
|
+
expiresAt: number | null;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type IpBlockRefusal = 'ip_block.invalid' | 'ip_block.limit_exceeded';
|
|
21
|
+
|
|
22
|
+
export type IpBlockResult = { ok: true; block: IpBlock } | { ok: false; refuse: IpBlockRefusal };
|
|
23
|
+
|
|
24
|
+
interface StoredBlock {
|
|
25
|
+
cidr: string;
|
|
26
|
+
reason: string | null;
|
|
27
|
+
created_at: string;
|
|
28
|
+
expires_at: string | null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const block = (row: StoredBlock): IpBlock => ({
|
|
32
|
+
cidr: row.cidr,
|
|
33
|
+
reason: row.reason,
|
|
34
|
+
createdAt: Number(row.created_at),
|
|
35
|
+
expiresAt: row.expires_at === null ? null : Number(row.expires_at),
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/** An address or a network in CIDR form, or null. Host bits are allowed here and cleared on write. */
|
|
39
|
+
function parseCidr(value: unknown): { address: string; prefix: number; family: 4 | 6 } | null {
|
|
40
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > 64) return null;
|
|
41
|
+
const [address, prefixText, ...rest] = value.trim().split('/');
|
|
42
|
+
if (!address || rest.length > 0) return null;
|
|
43
|
+
const family = isIP(address);
|
|
44
|
+
if (family !== 4 && family !== 6) return null;
|
|
45
|
+
const widest = family === 4 ? 32 : 128;
|
|
46
|
+
if (prefixText === undefined) return { address, prefix: widest, family };
|
|
47
|
+
if (!/^\d{1,3}$/.test(prefixText)) return null;
|
|
48
|
+
const prefix = Number(prefixText);
|
|
49
|
+
// /0 would block every caller. That is turning the gate off, and not what a block list is for.
|
|
50
|
+
if (prefix < 1 || prefix > widest) return null;
|
|
51
|
+
return { address, prefix, family };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Addresses and networks this gate refuses, kept in Postgres and matched in memory.
|
|
56
|
+
*
|
|
57
|
+
* The table is the record; the in-memory list is what a request is checked against, because an
|
|
58
|
+
* admission check runs on every action and a query per action is not affordable. The list is
|
|
59
|
+
* rebuilt after every write through this instance and at most every `refreshMs` otherwise, so a
|
|
60
|
+
* block written by another process takes effect within that interval.
|
|
61
|
+
*/
|
|
62
|
+
export class IpBlocks {
|
|
63
|
+
private matcher = new BlockList();
|
|
64
|
+
private loadedAt = -Infinity;
|
|
65
|
+
private loading: Promise<void> | null = null;
|
|
66
|
+
|
|
67
|
+
constructor(
|
|
68
|
+
private readonly pool: Pool,
|
|
69
|
+
private readonly refreshMs = DEFAULT_REFRESH_MS,
|
|
70
|
+
) {}
|
|
71
|
+
|
|
72
|
+
/** Whether an address is blocked. Loads the list on first use and refreshes it when stale. */
|
|
73
|
+
async blocks(ip: string | null, now = Date.now()): Promise<boolean> {
|
|
74
|
+
if (!ip) return false;
|
|
75
|
+
if (this.loadedAt === -Infinity) await this.reload(now);
|
|
76
|
+
// A stale list is refreshed behind the request: the answer is at most one interval old.
|
|
77
|
+
else if (now - this.loadedAt >= this.refreshMs) void this.reload(now).catch(() => undefined);
|
|
78
|
+
const address = normaliseIp(ip);
|
|
79
|
+
const family = isIP(address);
|
|
80
|
+
if (family !== 4 && family !== 6) return false;
|
|
81
|
+
return this.matcher.check(address, family === 4 ? 'ipv4' : 'ipv6');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Block an address or network. Blocking it again updates the reason and expiry. */
|
|
85
|
+
async add(input: { cidr?: unknown; reason?: unknown; expiresAt?: unknown }, now = Date.now()): Promise<IpBlockResult> {
|
|
86
|
+
const parsed = parseCidr(input?.cidr);
|
|
87
|
+
const reason = input?.reason ?? null;
|
|
88
|
+
const expiresAt = input?.expiresAt ?? null;
|
|
89
|
+
if (
|
|
90
|
+
!parsed ||
|
|
91
|
+
(reason !== null && (typeof reason !== 'string' || reason.length > MAX_REASON_LENGTH)) ||
|
|
92
|
+
(expiresAt !== null && (!Number.isSafeInteger(expiresAt) || (expiresAt as number) <= now))
|
|
93
|
+
) {
|
|
94
|
+
return { ok: false, refuse: 'ip_block.invalid' };
|
|
95
|
+
}
|
|
96
|
+
const result = await this.pool.query<StoredBlock>(
|
|
97
|
+
`insert into ip_blocks (cidr, reason, created_at, expires_at)
|
|
98
|
+
select network(set_masklen($1::inet, $2)), $3, $4, $5
|
|
99
|
+
where (select count(*) from ip_blocks) < $6
|
|
100
|
+
or exists (select 1 from ip_blocks where cidr = network(set_masklen($1::inet, $2)))
|
|
101
|
+
on conflict (cidr) do update set reason = excluded.reason, expires_at = excluded.expires_at
|
|
102
|
+
returning cidr::text, reason, created_at, expires_at`,
|
|
103
|
+
[parsed.address, parsed.prefix, reason, now, expiresAt, MAX_IP_BLOCKS],
|
|
104
|
+
);
|
|
105
|
+
const row = result.rows[0];
|
|
106
|
+
if (!row) return { ok: false, refuse: 'ip_block.limit_exceeded' };
|
|
107
|
+
await this.reload(now);
|
|
108
|
+
return { ok: true, block: block(row) };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Remove a block. True if there was one. */
|
|
112
|
+
async remove(cidr: unknown, now = Date.now()): Promise<boolean | null> {
|
|
113
|
+
const parsed = parseCidr(cidr);
|
|
114
|
+
if (!parsed) return null;
|
|
115
|
+
const result = await this.pool.query(
|
|
116
|
+
'delete from ip_blocks where cidr = network(set_masklen($1::inet, $2))',
|
|
117
|
+
[parsed.address, parsed.prefix],
|
|
118
|
+
);
|
|
119
|
+
await this.reload(now);
|
|
120
|
+
return (result.rowCount ?? 0) > 0;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Every block still in force, newest first. */
|
|
124
|
+
async list(now = Date.now()): Promise<IpBlock[]> {
|
|
125
|
+
const result = await this.pool.query<StoredBlock>(
|
|
126
|
+
`select cidr::text, reason, created_at, expires_at from ip_blocks
|
|
127
|
+
where expires_at is null or expires_at > $1
|
|
128
|
+
order by created_at desc, cidr`,
|
|
129
|
+
[now],
|
|
130
|
+
);
|
|
131
|
+
return result.rows.map(block);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
private reload(now: number): Promise<void> {
|
|
135
|
+
this.loading ??= (async () => {
|
|
136
|
+
try {
|
|
137
|
+
// Expired rows are cleared here rather than by a timer: a gate nobody calls has no work to do.
|
|
138
|
+
await this.pool.query('delete from ip_blocks where expires_at is not null and expires_at <= $1', [now]);
|
|
139
|
+
const rows = await this.pool.query<{ address: string; prefix: number; family: number }>(
|
|
140
|
+
'select host(cidr) as address, masklen(cidr) as prefix, family(cidr) as family from ip_blocks',
|
|
141
|
+
);
|
|
142
|
+
const next = new BlockList();
|
|
143
|
+
for (const row of rows.rows) {
|
|
144
|
+
next.addSubnet(row.address, Number(row.prefix), Number(row.family) === 4 ? 'ipv4' : 'ipv6');
|
|
145
|
+
}
|
|
146
|
+
this.matcher = next;
|
|
147
|
+
this.loadedAt = now;
|
|
148
|
+
} finally {
|
|
149
|
+
this.loading = null;
|
|
150
|
+
}
|
|
151
|
+
})();
|
|
152
|
+
return this.loading;
|
|
153
|
+
}
|
|
154
|
+
}
|