@flayerlabs/gamemode-gate 0.1.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/LICENSE +9 -0
- package/README.md +79 -0
- package/dist/chain/discover.d.ts +158 -0
- package/dist/chain/discover.d.ts.map +1 -0
- package/dist/chain/discover.js +169 -0
- package/dist/chain/discover.js.map +1 -0
- package/dist/chain/signer.d.ts +46 -0
- package/dist/chain/signer.d.ts.map +1 -0
- package/dist/chain/signer.js +60 -0
- package/dist/chain/signer.js.map +1 -0
- package/dist/claims.d.ts +62 -0
- package/dist/claims.d.ts.map +1 -0
- package/dist/claims.js +128 -0
- package/dist/claims.js.map +1 -0
- package/dist/demo.d.ts +40 -0
- package/dist/demo.d.ts.map +1 -0
- package/dist/demo.js +90 -0
- package/dist/demo.js.map +1 -0
- package/dist/economy.d.ts +46 -0
- package/dist/economy.d.ts.map +1 -0
- package/dist/economy.js +100 -0
- package/dist/economy.js.map +1 -0
- package/dist/game-registry.d.ts +17 -0
- package/dist/game-registry.d.ts.map +1 -0
- package/dist/game-registry.js +90 -0
- package/dist/game-registry.js.map +1 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +14 -0
- package/dist/index.js.map +1 -0
- package/dist/ledger.d.ts +87 -0
- package/dist/ledger.d.ts.map +1 -0
- package/dist/ledger.js +222 -0
- package/dist/ledger.js.map +1 -0
- package/dist/registry.d.ts +55 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +164 -0
- package/dist/registry.js.map +1 -0
- package/dist/room.d.ts +98 -0
- package/dist/room.d.ts.map +1 -0
- package/dist/room.js +215 -0
- package/dist/room.js.map +1 -0
- package/dist/server.d.ts +92 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +695 -0
- package/dist/server.js.map +1 -0
- package/dist/sessions.d.ts +40 -0
- package/dist/sessions.d.ts.map +1 -0
- package/dist/sessions.js +144 -0
- package/dist/sessions.js.map +1 -0
- package/dist/settlement.d.ts +24 -0
- package/dist/settlement.d.ts.map +1 -0
- package/dist/settlement.js +69 -0
- package/dist/settlement.js.map +1 -0
- package/dist/store.d.ts +24 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +125 -0
- package/dist/store.js.map +1 -0
- package/dist/turnstile.d.ts +19 -0
- package/dist/turnstile.d.ts.map +1 -0
- package/dist/turnstile.js +61 -0
- package/dist/turnstile.js.map +1 -0
- package/package.json +54 -0
- package/src/chain/discover.ts +243 -0
- package/src/chain/signer.ts +118 -0
- package/src/claims.ts +140 -0
- package/src/demo.ts +149 -0
- package/src/economy.ts +126 -0
- package/src/game-registry.ts +107 -0
- package/src/index.ts +45 -0
- package/src/ledger.ts +355 -0
- package/src/registry.ts +212 -0
- package/src/room.ts +297 -0
- package/src/server.ts +833 -0
- package/src/sessions.ts +155 -0
- package/src/settlement.ts +72 -0
- package/src/store.ts +127 -0
- package/src/turnstile.ts +74 -0
package/src/sessions.ts
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { isAddress, verifyMessage } from 'viem';
|
|
3
|
+
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
4
|
+
import { formatSessionChallenge } from '@flayerlabs/gamemode-spec/embed';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Proving who a player is, once, and carrying it afterwards.
|
|
8
|
+
*
|
|
9
|
+
* A player signs a message with the wallet they are going to buy with; from then on they hold a
|
|
10
|
+
* token. Two details are load-bearing and both were learned the hard way in the system this
|
|
11
|
+
* replaces:
|
|
12
|
+
*
|
|
13
|
+
* **The message names the page the player is looking at, not the game.** A game runs in an iframe
|
|
14
|
+
* on flaunch.gg, so a signing prompt naming the game's own origin is one wallets flag as deceptive
|
|
15
|
+
* — and rightly, because the player is looking at flaunch.gg. The message separately names this
|
|
16
|
+
* gate's configured origin, which lets the parent refuse a challenge replayed from another gate.
|
|
17
|
+
*
|
|
18
|
+
* **The session is a token, never a cookie.** A cross-origin iframe gets partitioned storage, so a
|
|
19
|
+
* cookie set by the gate is not reliably there on the next request.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** Long enough to sign in without hurrying, short enough that a stale prompt stops working. */
|
|
23
|
+
const NONCE_TTL_MS = 2 * 60_000;
|
|
24
|
+
|
|
25
|
+
/** A round is minutes; a session outliving the day it was made has no purpose. */
|
|
26
|
+
const SESSION_TTL_MS = 12 * 60 * 60_000;
|
|
27
|
+
|
|
28
|
+
export class NotSignedIn extends Error {
|
|
29
|
+
constructor(message = 'not signed in') {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'NotSignedIn';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A signed envelope: base64url JSON, then an HMAC of it.
|
|
37
|
+
*
|
|
38
|
+
* There is one of these because there used to be two, and both parsed by dot-joining fields and
|
|
39
|
+
* splitting them apart again. That let an address containing a dot smuggle an extra field into the
|
|
40
|
+
* body: `challenge('0xabc….Infinity')` produced an expiry of `Infinity`, and `Infinity < now` is
|
|
41
|
+
* false, so a captured signature could mint fresh sessions forever. JSON has no such ambiguity, and
|
|
42
|
+
* the shape is checked rather than assumed.
|
|
43
|
+
*/
|
|
44
|
+
interface Envelope {
|
|
45
|
+
subject: string;
|
|
46
|
+
expiresAt: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class Sessions {
|
|
50
|
+
constructor(
|
|
51
|
+
private readonly secret: string,
|
|
52
|
+
/** The origin the player is actually looking at — the embedding page, not the game. */
|
|
53
|
+
private readonly domain: string,
|
|
54
|
+
/** Exact configured origin of this gate. Never derive this audience from a request header. */
|
|
55
|
+
private readonly gateOrigin: string,
|
|
56
|
+
private readonly now: () => number = Date.now,
|
|
57
|
+
) {
|
|
58
|
+
if (!secret || secret.length < 32) {
|
|
59
|
+
throw new Error('session secret must be at least 32 characters');
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const gate = new URL(gateOrigin);
|
|
63
|
+
if ((gate.protocol !== 'https:' && gate.protocol !== 'http:') || gate.origin !== gateOrigin) throw new Error();
|
|
64
|
+
} catch {
|
|
65
|
+
throw new Error('gate origin must be an exact http(s) origin');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private sign(value: string): string {
|
|
70
|
+
return createHmac('sha256', this.secret).update(value).digest('base64url');
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private seal(envelope: Envelope): string {
|
|
74
|
+
const body = Buffer.from(JSON.stringify(envelope)).toString('base64url');
|
|
75
|
+
return `${body}.${this.sign(body)}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Open an envelope, or null. Every field is checked; nothing is inferred from position. */
|
|
79
|
+
private open(value: string | undefined): Envelope | null {
|
|
80
|
+
if (!value) return null;
|
|
81
|
+
const parts = value.split('.');
|
|
82
|
+
if (parts.length !== 2) return null;
|
|
83
|
+
|
|
84
|
+
const [body, mac] = parts as [string, string];
|
|
85
|
+
if (!this.matches(body, mac)) return null;
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const parsed: unknown = JSON.parse(Buffer.from(body, 'base64url').toString());
|
|
89
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
90
|
+
const { subject, expiresAt } = parsed as Record<string, unknown>;
|
|
91
|
+
if (typeof subject !== 'string' || !subject) return null;
|
|
92
|
+
// Finite and whole: `Infinity` and `NaN` both slip past a naive `<` comparison.
|
|
93
|
+
if (typeof expiresAt !== 'number' || !Number.isSafeInteger(expiresAt)) return null;
|
|
94
|
+
return { subject, expiresAt };
|
|
95
|
+
} catch {
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
private matches(value: string, signature: string): boolean {
|
|
101
|
+
const expected = Buffer.from(this.sign(value));
|
|
102
|
+
const given = Buffer.from(signature);
|
|
103
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A challenge to sign.
|
|
108
|
+
*
|
|
109
|
+
* Carries its own expiry and signature rather than being stored, so a restart mid-sign-in does
|
|
110
|
+
* not strand anyone. It is deliberately NOT single-use: doing that needs storage, and the only
|
|
111
|
+
* party who ever sees the signature is the player's own game, which already holds their session.
|
|
112
|
+
*/
|
|
113
|
+
challenge(address: string): { nonce: string; message: string } {
|
|
114
|
+
// Checked before anything is signed: an address is the one field a caller controls, and the
|
|
115
|
+
// envelope's meaning depends on it being what it claims to be.
|
|
116
|
+
if (!isAddress(address)) throw new NotSignedIn('that is not a wallet address');
|
|
117
|
+
const nonce = this.seal({ subject: address.toLowerCase(), expiresAt: this.now() + NONCE_TTL_MS });
|
|
118
|
+
return { nonce, message: this.messageFor(address, nonce) };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private messageFor(address: string, nonce: string): string {
|
|
122
|
+
return formatSessionChallenge(this.domain, address, this.gateOrigin, nonce);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Check a signed challenge and issue a session token, or explain why not. */
|
|
126
|
+
async signIn(address: string, signature: `0x${string}`, nonce: string): Promise<string> {
|
|
127
|
+
if (!isAddress(address)) throw new NotSignedIn('that is not a wallet address');
|
|
128
|
+
|
|
129
|
+
const opened = this.open(nonce);
|
|
130
|
+
if (!opened) throw new NotSignedIn('that sign-in code is not valid');
|
|
131
|
+
if (opened.subject !== address.toLowerCase()) throw new NotSignedIn('that sign-in code is for another wallet');
|
|
132
|
+
if (opened.expiresAt < this.now()) throw new NotSignedIn('that sign-in code has expired, please try again');
|
|
133
|
+
|
|
134
|
+
const valid = await verifyMessage({
|
|
135
|
+
address: address as `0x${string}`,
|
|
136
|
+
message: this.messageFor(address, nonce),
|
|
137
|
+
signature,
|
|
138
|
+
}).catch(() => false);
|
|
139
|
+
if (!valid) throw new NotSignedIn('that signature does not match the wallet');
|
|
140
|
+
|
|
141
|
+
return this.issue(address.toLowerCase());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private issue(player: PlayerId): string {
|
|
145
|
+
return this.seal({ subject: player, expiresAt: this.now() + SESSION_TTL_MS });
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Who is this, or throw. The only way a request gets an identity. */
|
|
149
|
+
verify(token: string | undefined): PlayerId {
|
|
150
|
+
const opened = this.open(token);
|
|
151
|
+
if (!opened) throw new NotSignedIn();
|
|
152
|
+
if (opened.expiresAt < this.now()) throw new NotSignedIn('your session has expired');
|
|
153
|
+
return opened.subject;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { parseAbi } from 'viem';
|
|
2
|
+
import type { Pool } from 'pg';
|
|
3
|
+
import type { PlayerId } from '@flayerlabs/gamemode-spec';
|
|
4
|
+
import type { Ledger } from './ledger.js';
|
|
5
|
+
import type { ChainReader } from './chain/discover.js';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keeping the ledger honest about what was actually spent.
|
|
9
|
+
*
|
|
10
|
+
* The obvious design is to watch for a swap and match it back to the authorisation that permitted
|
|
11
|
+
* it. That turns out to be both harder and less useful than it sounds: the event carries the buyer
|
|
12
|
+
* and the amount but not the nonce, and a wallet can submit through a relay, so correlating them is
|
|
13
|
+
* guesswork.
|
|
14
|
+
*
|
|
15
|
+
* The contract already keeps the number that matters. `walletSpentWei(poolId, wallet)` is what it
|
|
16
|
+
* enforces against, so reading it is reading the truth rather than inferring it. No matching, no
|
|
17
|
+
* event log to replay, and nothing to get subtly wrong.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const spendGatedAbi = parseAbi([
|
|
21
|
+
'function walletSpentWei(bytes32 _poolId, address _wallet) view returns (uint256)',
|
|
22
|
+
]);
|
|
23
|
+
|
|
24
|
+
export class Settlement {
|
|
25
|
+
constructor(
|
|
26
|
+
private readonly pool: Pool,
|
|
27
|
+
private readonly ledger: Ledger,
|
|
28
|
+
private readonly client: ChainReader,
|
|
29
|
+
private readonly calculator: `0x${string}`,
|
|
30
|
+
) {}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Bring one round's view of spending up to date with the chain.
|
|
34
|
+
*
|
|
35
|
+
* Returns which wallets changed as well as read/failure counts, so live updates can target only
|
|
36
|
+
* affected players. A read that fails is skipped rather than thrown: one unreachable RPC call
|
|
37
|
+
* must not stop a round, and the next pass will catch it.
|
|
38
|
+
*/
|
|
39
|
+
async reconcile(roundId: string): Promise<{ read: number; failed: number; changed: PlayerId[] }> {
|
|
40
|
+
const round = await this.pool.query<{ pool_id: string }>(`select pool_id from rounds where id = $1`, [
|
|
41
|
+
roundId,
|
|
42
|
+
]);
|
|
43
|
+
const poolId = round.rows[0]?.pool_id;
|
|
44
|
+
if (!poolId) return { read: 0, failed: 0, changed: [] };
|
|
45
|
+
|
|
46
|
+
const players = await this.ledger.playersIn(roundId);
|
|
47
|
+
let read = 0;
|
|
48
|
+
let failed = 0;
|
|
49
|
+
const changed: PlayerId[] = [];
|
|
50
|
+
|
|
51
|
+
for (const player of players) {
|
|
52
|
+
try {
|
|
53
|
+
const spent = (await this.client.readContract({
|
|
54
|
+
address: this.calculator,
|
|
55
|
+
abi: spendGatedAbi,
|
|
56
|
+
functionName: 'walletSpentWei',
|
|
57
|
+
args: [poolId as `0x${string}`, player as `0x${string}`],
|
|
58
|
+
})) as bigint;
|
|
59
|
+
|
|
60
|
+
if (spent > 0n && await this.ledger.recordChainSpend(roundId, player, spent)) changed.push(player);
|
|
61
|
+
read += 1;
|
|
62
|
+
} catch {
|
|
63
|
+
// One unreachable read is not a reason to stop the round, but it is counted. Swallowed
|
|
64
|
+
// entirely, a chain that has been unreachable all day looks exactly like one where nobody
|
|
65
|
+
// has spent anything — and the second is a fine reason to keep authorising, while the
|
|
66
|
+
// first is not.
|
|
67
|
+
failed += 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { read, failed, changed };
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { Pool, PoolClient } from 'pg';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The complete first-release database schema.
|
|
5
|
+
*
|
|
6
|
+
* Every table is here rather than beside the code that reads it. Two modules owning parts of one
|
|
7
|
+
* table is how a schema drifts: one adds a column, the other's migration never runs, and the
|
|
8
|
+
* failure appears somewhere neither of them looks.
|
|
9
|
+
*/
|
|
10
|
+
export const SCHEMA = `
|
|
11
|
+
create table if not exists rounds (
|
|
12
|
+
id text primary key,
|
|
13
|
+
wei_per_point numeric(78,0) not null check (wei_per_point > 0),
|
|
14
|
+
wallet_cap_wei numeric(78,0) not null check (wallet_cap_wei > 0),
|
|
15
|
+
pool_id text not null,
|
|
16
|
+
opens_at bigint not null,
|
|
17
|
+
closes_at bigint not null,
|
|
18
|
+
books_close_at bigint not null,
|
|
19
|
+
coin_address text,
|
|
20
|
+
coin_name text not null,
|
|
21
|
+
coin_symbol text not null,
|
|
22
|
+
coin_image_url text,
|
|
23
|
+
-- The game's own state, opaque to the platform. Written after every command, so a restart
|
|
24
|
+
-- resumes from here rather than replaying history.
|
|
25
|
+
state jsonb not null,
|
|
26
|
+
-- Commands applied so far. Doubles as the cursor a reconnecting client resumes from.
|
|
27
|
+
seq integer not null default 0 check (seq >= 0),
|
|
28
|
+
constraint rounds_window check (opens_at < closes_at),
|
|
29
|
+
constraint rounds_books_tail check (books_close_at >= closes_at)
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
create table if not exists balances (
|
|
33
|
+
round_id text not null references rounds(id) on delete cascade,
|
|
34
|
+
player text not null,
|
|
35
|
+
points bigint not null default 0 check (points >= 0),
|
|
36
|
+
-- What the chain says this wallet has already spent on this pool. The contract keeps this and
|
|
37
|
+
-- enforces it whatever we believe, so it is the only figure that is actually true.
|
|
38
|
+
chain_spent_wei numeric(78,0) not null default 0 check (chain_spent_wei >= 0),
|
|
39
|
+
primary key (round_id, player)
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
-- One row per authorisation ever issued. Nothing is deleted: a signature that exists in the world
|
|
43
|
+
-- cannot be un-issued, so the ledger records its fate instead of forgetting it.
|
|
44
|
+
create table if not exists claims (
|
|
45
|
+
nonce bigint generated always as identity primary key,
|
|
46
|
+
round_id text not null references rounds(id) on delete cascade,
|
|
47
|
+
player text not null,
|
|
48
|
+
request_id varchar(128) not null check (request_id <> ''),
|
|
49
|
+
requested_wei numeric(78,0) not null check (requested_wei > 0),
|
|
50
|
+
wei numeric(78,0) not null check (wei > 0 and wei <= requested_wei),
|
|
51
|
+
-- Held or released, and nothing else. Whether a swap LANDED is the chain's to say, not ours.
|
|
52
|
+
status text not null check (status in ('held', 'released')),
|
|
53
|
+
deadline bigint not null,
|
|
54
|
+
-- Persisted before a payload leaves the gate. Until signing completes, both values are null.
|
|
55
|
+
signature text,
|
|
56
|
+
signer text,
|
|
57
|
+
created_at timestamptz not null default now(),
|
|
58
|
+
constraint claims_by_request unique (round_id, player, request_id),
|
|
59
|
+
constraint claims_signature_pair check ((signature is null) = (signer is null))
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
create index if not exists claims_by_player on claims (round_id, player) where status = 'held';
|
|
63
|
+
|
|
64
|
+
-- Which game a coin plays. Addresses are chain-local, so the pin is one row per (chain, coin).
|
|
65
|
+
create table if not exists registrations (
|
|
66
|
+
chain_id bigint not null check (chain_id > 0),
|
|
67
|
+
coin text not null check (coin <> ''),
|
|
68
|
+
game_id text not null check (game_id <> ''),
|
|
69
|
+
gate_origin text not null check (gate_origin <> ''),
|
|
70
|
+
deploy_id text not null check (deploy_id <> ''),
|
|
71
|
+
disabled_at timestamptz,
|
|
72
|
+
updated_at timestamptz not null default now(),
|
|
73
|
+
primary key (chain_id, coin)
|
|
74
|
+
);
|
|
75
|
+
|
|
76
|
+
-- What actually happened, in order. Not used to rebuild state — the snapshot does that — but it is
|
|
77
|
+
-- the record of why a state looks the way it does, which is what an incident needs.
|
|
78
|
+
create table if not exists commands (
|
|
79
|
+
round_id text not null references rounds(id) on delete cascade,
|
|
80
|
+
seq integer not null check (seq > 0),
|
|
81
|
+
command jsonb not null,
|
|
82
|
+
primary key (round_id, seq)
|
|
83
|
+
);
|
|
84
|
+
`;
|
|
85
|
+
|
|
86
|
+
const MIGRATION_LOCK = `select pg_advisory_xact_lock(
|
|
87
|
+
hashtextextended(current_database() || ':' || current_schema() || ':gamemode_schema', 0)
|
|
88
|
+
)`;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run `fn` inside a transaction, committing on success and rolling back on failure.
|
|
92
|
+
*
|
|
93
|
+
* The rollback is itself wrapped, because a connection that has already died throws again on
|
|
94
|
+
* `rollback` — and an unguarded rollback replaces the useful original error.
|
|
95
|
+
*/
|
|
96
|
+
export async function inTransaction<T>(pool: Pool, fn: (tx: PoolClient) => Promise<T>): Promise<T> {
|
|
97
|
+
const tx = await pool.connect();
|
|
98
|
+
try {
|
|
99
|
+
await tx.query('begin');
|
|
100
|
+
const result = await fn(tx);
|
|
101
|
+
await tx.query('commit');
|
|
102
|
+
return result;
|
|
103
|
+
} catch (error) {
|
|
104
|
+
try {
|
|
105
|
+
await tx.query('rollback');
|
|
106
|
+
} catch {
|
|
107
|
+
// Preserve the original error.
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
} finally {
|
|
111
|
+
tx.release();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Create the current schema once, serialising concurrent replica startup in this Postgres schema. */
|
|
116
|
+
export async function migrate(pool: Pool): Promise<void> {
|
|
117
|
+
await inTransaction(pool, async (tx) => {
|
|
118
|
+
await tx.query(MIGRATION_LOCK);
|
|
119
|
+
await tx.query(SCHEMA);
|
|
120
|
+
});
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Postgres hands back `numeric` and `bigint` as strings, because they do not fit a JS number.
|
|
125
|
+
* Parsing them in one place stops a `Number()` creeping in and silently losing wei precision.
|
|
126
|
+
*/
|
|
127
|
+
export const toBigInt = (value: string | number | null | undefined): bigint => BigInt(value ?? 0);
|
package/src/turnstile.ts
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Admit } from './server.js';
|
|
2
|
+
|
|
3
|
+
const SITEVERIFY = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
|
|
4
|
+
const MAX_TOKEN_LENGTH = 2_048;
|
|
5
|
+
const DEFAULT_TIMEOUT_MS = 8_000;
|
|
6
|
+
const FAILURE = { ok: false, reason: 'human verification failed, please try again' } as const;
|
|
7
|
+
|
|
8
|
+
export interface TurnstileAdmitOptions {
|
|
9
|
+
secret: string;
|
|
10
|
+
/** Exact hostnames on which the trusted parent may render the widget. */
|
|
11
|
+
hostnames: readonly string[];
|
|
12
|
+
/** The action configured on the widget. */
|
|
13
|
+
action: string;
|
|
14
|
+
timeoutMs?: number;
|
|
15
|
+
/** Test seam for the one external boundary. Production uses global fetch. */
|
|
16
|
+
fetch?: typeof globalThis.fetch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Reference Cloudflare Turnstile policy for the gate's generic admission seam.
|
|
21
|
+
*
|
|
22
|
+
* It verifies only session creation. Gameplay and claim policy remain composable operator choices.
|
|
23
|
+
* Every non-explicit success fails closed, including network, HTTP and malformed-response failures.
|
|
24
|
+
*/
|
|
25
|
+
export function createTurnstileAdmit(options: TurnstileAdmitOptions): Admit {
|
|
26
|
+
if (!options.secret) throw new RangeError('Turnstile secret is required');
|
|
27
|
+
if (!options.action) throw new RangeError('Turnstile action is required');
|
|
28
|
+
if (options.hostnames.length === 0) throw new RangeError('at least one Turnstile hostname is required');
|
|
29
|
+
const hostnames = new Set(options.hostnames.map((hostname) => hostname.toLowerCase()));
|
|
30
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
31
|
+
if (!Number.isSafeInteger(timeoutMs) || timeoutMs <= 0) {
|
|
32
|
+
throw new RangeError('Turnstile timeoutMs must be a positive safe integer');
|
|
33
|
+
}
|
|
34
|
+
const verify = options.fetch ?? globalThis.fetch;
|
|
35
|
+
|
|
36
|
+
return async (request) => {
|
|
37
|
+
if (request.at !== 'session') return { ok: true };
|
|
38
|
+
if (
|
|
39
|
+
typeof request.evidence !== 'string' ||
|
|
40
|
+
request.evidence.length === 0 ||
|
|
41
|
+
request.evidence.length > MAX_TOKEN_LENGTH
|
|
42
|
+
) {
|
|
43
|
+
return FAILURE;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const body = new URLSearchParams({
|
|
47
|
+
secret: options.secret,
|
|
48
|
+
response: request.evidence,
|
|
49
|
+
idempotency_key: globalThis.crypto.randomUUID(),
|
|
50
|
+
...(request.ip ? { remoteip: request.ip } : {}),
|
|
51
|
+
});
|
|
52
|
+
try {
|
|
53
|
+
const response = await verify(SITEVERIFY, {
|
|
54
|
+
method: 'POST',
|
|
55
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded' },
|
|
56
|
+
body,
|
|
57
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
58
|
+
});
|
|
59
|
+
if (!response.ok) return FAILURE;
|
|
60
|
+
const result = (await response.json()) as { success?: unknown; hostname?: unknown; action?: unknown };
|
|
61
|
+
if (
|
|
62
|
+
result.success !== true ||
|
|
63
|
+
typeof result.hostname !== 'string' ||
|
|
64
|
+
!hostnames.has(result.hostname.toLowerCase()) ||
|
|
65
|
+
result.action !== options.action
|
|
66
|
+
) {
|
|
67
|
+
return FAILURE;
|
|
68
|
+
}
|
|
69
|
+
return { ok: true };
|
|
70
|
+
} catch {
|
|
71
|
+
return FAILURE;
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
}
|