pog-mcp 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 +21 -0
- package/README.md +186 -0
- package/dist/client.d.ts +319 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +728 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +26 -0
- package/dist/index.js.map +1 -0
- package/dist/server.d.ts +19 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +867 -0
- package/dist/server.js.map +1 -0
- package/dist/wallet.d.ts +88 -0
- package/dist/wallet.d.ts.map +1 -0
- package/dist/wallet.js +562 -0
- package/dist/wallet.js.map +1 -0
- package/package.json +63 -0
- package/skill/SKILL.md +375 -0
- package/skill/reference/measurements.md +192 -0
package/dist/client.js
ADDED
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* client — typed HTTP access to the Proof of Goal API, with SIWS handled here.
|
|
3
|
+
*
|
|
4
|
+
* The whole point of this layer is that `login()` is one call. The real flow is
|
|
5
|
+
* four round-trips and an ed25519 signature:
|
|
6
|
+
*
|
|
7
|
+
* 1. GET /api/auth/nonce?wallet=… → single-use nonce
|
|
8
|
+
* 2. GET /api/auth/message?wallet=…&nonce=… → the exact text to sign
|
|
9
|
+
* 3. sign it locally
|
|
10
|
+
* 4. POST /api/auth/signin → bearer session
|
|
11
|
+
*
|
|
12
|
+
* Step 2 matters: the server builds the SIWS text (domain, URI, statement) and
|
|
13
|
+
* checks the parsed result on the way back in. Rebuilding that string here would
|
|
14
|
+
* work right up until SIWS_DOMAIN changes on a deploy and every login started
|
|
15
|
+
* failing signature verification for no visible reason. Ask, don't guess.
|
|
16
|
+
*/
|
|
17
|
+
import { signMessage } from './wallet.js';
|
|
18
|
+
/**
|
|
19
|
+
* Largest page GET /api/leaderboard will serve (its MAX_LIMIT). Asking for it
|
|
20
|
+
* makes truncation detectable: a full page means there may be more.
|
|
21
|
+
*/
|
|
22
|
+
export const LEADERBOARD_MAX_LIMIT = 500;
|
|
23
|
+
/** Tolerance for a server clock running ahead of ours. */
|
|
24
|
+
const CLOCK_SKEW_MS = 2 * 60 * 1000;
|
|
25
|
+
/**
|
|
26
|
+
* How long the API keeps a nonce (siws.ts NONCE_TTL_MS). A message issued
|
|
27
|
+
* before this is already dead on arrival, so signing it only hands out a
|
|
28
|
+
* signature for nothing.
|
|
29
|
+
*/
|
|
30
|
+
const NONCE_TTL_MS = 10 * 60 * 1000;
|
|
31
|
+
/** Public deployment. Override with POG_API_URL to point at a local server. */
|
|
32
|
+
export const DEFAULT_API_URL = 'https://api.pog.soccer';
|
|
33
|
+
export class ApiError extends Error {
|
|
34
|
+
status;
|
|
35
|
+
path;
|
|
36
|
+
/**
|
|
37
|
+
* The `teamId` some rejections carry alongside the error. POST /api/teams
|
|
38
|
+
* answers 409 `{error:"team_exists", teamId}` — the id is the whole point of
|
|
39
|
+
* that response, so it must not be flattened away into the message.
|
|
40
|
+
*/
|
|
41
|
+
teamId;
|
|
42
|
+
/**
|
|
43
|
+
* When to try again, in seconds — the whole point of a 429.
|
|
44
|
+
*
|
|
45
|
+
* The server always says this, twice over: `retry-after` on the response and,
|
|
46
|
+
* for the playoff cooldown, `retryAfterMs`/`nextMatchAt` in the body. Both
|
|
47
|
+
* used to be dropped on the floor, so an agent that woke a minute early got a
|
|
48
|
+
* sentence to read instead of a time to sleep until, and had to spend another
|
|
49
|
+
* call working out what the rejection already contained.
|
|
50
|
+
*/
|
|
51
|
+
retryAfterSeconds;
|
|
52
|
+
/** The exact instant the next attempt is allowed, when the server names one. */
|
|
53
|
+
retryAt;
|
|
54
|
+
/** Requests left in this window, so an agent can PACE instead of only backing off. */
|
|
55
|
+
remaining;
|
|
56
|
+
constructor(status, path, message, teamId, extra) {
|
|
57
|
+
super(message);
|
|
58
|
+
this.status = status;
|
|
59
|
+
this.path = path;
|
|
60
|
+
this.name = 'ApiError';
|
|
61
|
+
if (teamId !== undefined)
|
|
62
|
+
this.teamId = teamId;
|
|
63
|
+
if (extra?.retryAfterSeconds !== undefined)
|
|
64
|
+
this.retryAfterSeconds = extra.retryAfterSeconds;
|
|
65
|
+
if (extra?.retryAt !== undefined)
|
|
66
|
+
this.retryAt = extra.retryAt;
|
|
67
|
+
if (extra?.remaining !== undefined)
|
|
68
|
+
this.remaining = extra.remaining;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function parseSiws(message) {
|
|
72
|
+
const lines = message.split('\n');
|
|
73
|
+
const domainLine = lines[0] ?? '';
|
|
74
|
+
const address = (lines[1] ?? '').trim();
|
|
75
|
+
const m = /^(\S+) wants you to sign in with your Solana account:$/.exec(domainLine.trim());
|
|
76
|
+
if (!m?.[1] || address.length === 0)
|
|
77
|
+
return null;
|
|
78
|
+
// Read the labelled fields exactly the way the server's own parser does
|
|
79
|
+
// (packages/api/src/lib/siws.ts): from line 5 on, splitting at the first
|
|
80
|
+
// ": ". Two parsers with different rules over the same bytes is the whole
|
|
81
|
+
// attack — the server's loop overwrites, so it takes the LAST occurrence,
|
|
82
|
+
// while a `.find()` here took the FIRST. A benign Nonce followed by an
|
|
83
|
+
// attacker's duplicate would then pass this check and authorise something
|
|
84
|
+
// else entirely.
|
|
85
|
+
//
|
|
86
|
+
// Duplicates are refused rather than resolved. Agreeing on "last wins" would
|
|
87
|
+
// close today's gap and reopen it the moment either parser changed; a message
|
|
88
|
+
// that needs a tie-break rule is not one we should be signing.
|
|
89
|
+
const fields = new Map();
|
|
90
|
+
for (let i = 5; i < lines.length; i++) {
|
|
91
|
+
const line = lines[i] ?? '';
|
|
92
|
+
const colon = line.indexOf(': ');
|
|
93
|
+
if (colon === -1)
|
|
94
|
+
continue;
|
|
95
|
+
const key = line.slice(0, colon).trim();
|
|
96
|
+
if (fields.has(key))
|
|
97
|
+
return null;
|
|
98
|
+
fields.set(key, line.slice(colon + 2).trim());
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
domain: m[1],
|
|
102
|
+
address,
|
|
103
|
+
uri: fields.get('URI') ?? null,
|
|
104
|
+
version: fields.get('Version') ?? null,
|
|
105
|
+
chainId: fields.get('Chain ID') ?? null,
|
|
106
|
+
nonce: fields.get('Nonce') ?? null,
|
|
107
|
+
issuedAt: fields.get('Issued At') ?? null,
|
|
108
|
+
expirationTime: fields.get('Expiration Time') ?? null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Hosts this client will sign a sign-in for, given the API it was pointed at.
|
|
113
|
+
*
|
|
114
|
+
* Exactly two: the API host, and the same host with a leading `api.` removed —
|
|
115
|
+
* because api.pog.soccer serves sign-ins for pog.soccer. That is a precise rule.
|
|
116
|
+
* The previous version compared the last two labels, which called evil.co.uk and
|
|
117
|
+
* pog.co.uk the same site and would have signed for either.
|
|
118
|
+
*
|
|
119
|
+
* `POG_SIWS_DOMAIN` overrides it outright for deployments that split differently.
|
|
120
|
+
*/
|
|
121
|
+
/** Loopback hosts — an attacker cannot be on the other end of these. */
|
|
122
|
+
const LOOPBACK = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
123
|
+
/**
|
|
124
|
+
* Hosts this client will sign a sign-in for.
|
|
125
|
+
*
|
|
126
|
+
* The anchor is BUILT IN, not derived from POG_API_URL. Deriving it from the
|
|
127
|
+
* URL under suspicion was the flaw: point the client at evil.example and it
|
|
128
|
+
* would serve a message for evil.example, satisfy every field check, and get a
|
|
129
|
+
* signature from the persistent wallet. Thorough validation of a self-consistent
|
|
130
|
+
* forgery is still a forgery — the check has to start from something the
|
|
131
|
+
* attacker does not control.
|
|
132
|
+
*
|
|
133
|
+
* So: the known Proof of Goal domains by default; loopback automatically,
|
|
134
|
+
* because a local dev server is not a phishing target; anything else only with
|
|
135
|
+
* an explicit POG_SIWS_DOMAIN. A mistyped host therefore fails closed and says
|
|
136
|
+
* what to set.
|
|
137
|
+
*
|
|
138
|
+
* The ORIGIN has to clear that bar too, not just the domain in the message.
|
|
139
|
+
* Returning the production hosts for an arbitrary API URL left one attack whole:
|
|
140
|
+
* a hostile HTTPS host does not have to forge anything — it proxies the nonce
|
|
141
|
+
* and message straight from api.pog.soccer, hands back the genuine pog.soccer
|
|
142
|
+
* text, and every field check passes because every field is real. Then it keeps
|
|
143
|
+
* the signature and the session. HTTPS authenticates the relay, not the service
|
|
144
|
+
* behind it. So an origin we do not know is refused before a message is fetched.
|
|
145
|
+
*/
|
|
146
|
+
export function expectedSiwsHosts(apiOrigin, env = process.env) {
|
|
147
|
+
// HOSTNAME, not host: the port is not part of the identity being checked, and
|
|
148
|
+
// including it broke the documented local setup outright —
|
|
149
|
+
// POG_API_URL=http://localhost:3001 expects "localhost:3001" while the server
|
|
150
|
+
// signs for "localhost".
|
|
151
|
+
const host = hostnameOf(apiOrigin);
|
|
152
|
+
// Every loopback spelling is the same machine, and a dev server is not a
|
|
153
|
+
// phishing target.
|
|
154
|
+
const loopback = LOOPBACK.has(host);
|
|
155
|
+
// WHICH HOST WE TALK TO IS ITS OWN QUESTION, asked first.
|
|
156
|
+
//
|
|
157
|
+
// POG_SIWS_DOMAIN used to skip this check on the reasoning that "an explicit
|
|
158
|
+
// override IS the authorization". It is not — it authorizes the DOMAIN a
|
|
159
|
+
// message may name, and says nothing about where the message came from. With
|
|
160
|
+
// it set, a typo'd or tampered POG_API_URL could relay the real deployment's
|
|
161
|
+
// nonce and message, pass every field check (the fields are genuine), and keep
|
|
162
|
+
// the signature and session. The two halves have to be authorized separately.
|
|
163
|
+
const allowed = expectedApiHosts(env);
|
|
164
|
+
if (!loopback && !allowed.includes(host)) {
|
|
165
|
+
throw new Error(`Refusing to sign in through ${host}. This client only talks to ` +
|
|
166
|
+
`${allowed.join(', ')} or a local server — an unknown host can relay the real sign-in ` +
|
|
167
|
+
'message unchanged and keep the signature it collects, which no check on the message ' +
|
|
168
|
+
'can detect. Set POG_SIWS_DOMAIN to the domain your deployment signs for (its API may ' +
|
|
169
|
+
'then live on that host or on api.<domain>), or POG_API_HOST if the API is somewhere ' +
|
|
170
|
+
'else entirely.');
|
|
171
|
+
}
|
|
172
|
+
const override = env['POG_SIWS_DOMAIN']?.trim();
|
|
173
|
+
if (override)
|
|
174
|
+
return [hostnameOf(override)];
|
|
175
|
+
// The API defaults SIWS_DOMAIN to "localhost" regardless of which name you
|
|
176
|
+
// dialled it by, so returning only the dialled one made
|
|
177
|
+
// POG_API_URL=http://127.0.0.1:3001 refuse a perfectly good local login —
|
|
178
|
+
// while the README promised both forms needed no override.
|
|
179
|
+
if (loopback)
|
|
180
|
+
return [...LOOPBACK];
|
|
181
|
+
return TRUSTED_SIWS_HOSTS;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Hosts this client will send a wallet address to.
|
|
185
|
+
*
|
|
186
|
+
* Separate from the domains a MESSAGE may name, because they answer different
|
|
187
|
+
* questions and a deployment can legitimately split them: the product's own API
|
|
188
|
+
* lives at api.pog.soccer and signs for pog.soccer. So an operator who declares
|
|
189
|
+
* their domain gets that same relation for free — the domain itself, or `api.`
|
|
190
|
+
* in front of it — and POG_API_HOST is there for the topologies that are neither.
|
|
191
|
+
*/
|
|
192
|
+
export function expectedApiHosts(env = process.env) {
|
|
193
|
+
const explicit = env['POG_API_HOST']?.trim();
|
|
194
|
+
if (explicit)
|
|
195
|
+
return [hostnameOf(explicit)];
|
|
196
|
+
const domain = env['POG_SIWS_DOMAIN']?.trim();
|
|
197
|
+
if (domain) {
|
|
198
|
+
const d = hostnameOf(domain);
|
|
199
|
+
return [d, `api.${d}`];
|
|
200
|
+
}
|
|
201
|
+
return TRUSTED_SIWS_HOSTS;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* The product's own domains, derived from DEFAULT_API_URL so the two cannot
|
|
205
|
+
* drift: the API host and the site it signs people in to.
|
|
206
|
+
*/
|
|
207
|
+
export const TRUSTED_SIWS_HOSTS = (() => {
|
|
208
|
+
const api = new URL(DEFAULT_API_URL).hostname.toLowerCase();
|
|
209
|
+
return api.startsWith('api.') ? [api, api.slice(4)] : [api];
|
|
210
|
+
})();
|
|
211
|
+
/**
|
|
212
|
+
* The hostname of an origin, a bare host, or a host:port — lowercased, port
|
|
213
|
+
* dropped. Accepts all three because the SIWS `domain` field is an RFC 3986
|
|
214
|
+
* authority and servers differ on whether they include the port.
|
|
215
|
+
*/
|
|
216
|
+
function hostnameOf(value) {
|
|
217
|
+
const trimmed = value.trim().toLowerCase();
|
|
218
|
+
// Only parse as a URL when it actually carries a scheme. `new URL` accepts
|
|
219
|
+
// "localhost:3001" as scheme "localhost:" with path "3001" and reports an
|
|
220
|
+
// EMPTY hostname — which would have compared equal for every such input.
|
|
221
|
+
if (/^[a-z][a-z0-9+.-]*:\/\//.test(trimmed)) {
|
|
222
|
+
try {
|
|
223
|
+
return new URL(trimmed).hostname;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
// Fall through and treat it as an authority.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
// A bare authority: strip any :port. IPv6 literals keep their brackets, which
|
|
230
|
+
// is what URL.hostname gives too.
|
|
231
|
+
if (trimmed.startsWith('['))
|
|
232
|
+
return /^\[[^\]]*\]/.exec(trimmed)?.[0] ?? trimmed;
|
|
233
|
+
return trimmed.split(':')[0] ?? trimmed;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Refuse to run the sign-in flow over plaintext to a remote host.
|
|
237
|
+
*
|
|
238
|
+
* Loopback is exempt: there is no path for anyone to sit on.
|
|
239
|
+
*/
|
|
240
|
+
export function assertTransportIsSafe(origin) {
|
|
241
|
+
let url;
|
|
242
|
+
try {
|
|
243
|
+
url = new URL(origin);
|
|
244
|
+
}
|
|
245
|
+
catch {
|
|
246
|
+
throw new Error(`Refusing to sign in: ${origin} is not a valid URL.`);
|
|
247
|
+
}
|
|
248
|
+
if (url.protocol === 'https:')
|
|
249
|
+
return;
|
|
250
|
+
if (LOOPBACK.has(url.hostname.toLowerCase()))
|
|
251
|
+
return;
|
|
252
|
+
throw new Error(`Refusing to sign in over ${url.protocol}// to ${url.host}. Anyone on the path could relay ` +
|
|
253
|
+
'a genuine sign-in message and keep the signature and session it produces. Use https, or ' +
|
|
254
|
+
'point POG_API_URL at localhost for local work.');
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* The site host this product signs people IN TO, as opposed to the API host.
|
|
258
|
+
*/
|
|
259
|
+
const TRUSTED_SITE_HOST = TRUSTED_SIWS_HOSTS[TRUSTED_SIWS_HOSTS.length - 1] ?? new URL(DEFAULT_API_URL).hostname;
|
|
260
|
+
/**
|
|
261
|
+
* The exact `domain` a message may name, or `null` to check only the host.
|
|
262
|
+
*
|
|
263
|
+
* Loopback stays host-only: the local API signs "localhost" whichever spelling
|
|
264
|
+
* and port you dialled it by, and pinning that would refuse a perfectly good
|
|
265
|
+
* local login — the same exemption the host and URI checks already make.
|
|
266
|
+
*/
|
|
267
|
+
export function expectedSiwsDomain(apiOrigin, env = process.env) {
|
|
268
|
+
const override = env['POG_SIWS_DOMAIN']?.trim();
|
|
269
|
+
if (override)
|
|
270
|
+
return override;
|
|
271
|
+
if (LOOPBACK.has(hostnameOf(apiOrigin)))
|
|
272
|
+
return null;
|
|
273
|
+
return TRUSTED_SITE_HOST;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The exact URI(s) a message may name, or `null` to check only the host.
|
|
277
|
+
*
|
|
278
|
+
* The API builds this as `https://{SIWS_DOMAIN}` unless SIWS_URI overrides it,
|
|
279
|
+
* so the expected value is derivable and worth pinning: a compromised endpoint
|
|
280
|
+
* that can only vary scheme and path still gets a signature over a resource the
|
|
281
|
+
* operator never agreed to.
|
|
282
|
+
*
|
|
283
|
+
* Loopback stays host-only. Ports and schemes vary freely on a dev box, it is
|
|
284
|
+
* not a phishing target — the same exemption the host check already makes — and
|
|
285
|
+
* pinning it would only teach people to set an override they do not need.
|
|
286
|
+
*/
|
|
287
|
+
export function expectedSiwsUris(apiOrigin, env = process.env) {
|
|
288
|
+
const override = env['POG_SIWS_URI']?.trim();
|
|
289
|
+
if (override) {
|
|
290
|
+
try {
|
|
291
|
+
return [normalizeUri(new URL(override))];
|
|
292
|
+
}
|
|
293
|
+
catch {
|
|
294
|
+
throw new Error(`POG_SIWS_URI is not a URL: ${override}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
if (LOOPBACK.has(hostnameOf(apiOrigin)))
|
|
298
|
+
return null;
|
|
299
|
+
// The AUTHORITY, verbatim — the API builds `https://${SIWS_DOMAIN}`, port and
|
|
300
|
+
// all. Reducing it with hostnameOf() made a deployment on
|
|
301
|
+
// POG_SIWS_DOMAIN=staging.example:8443 expect https://staging.example, so the
|
|
302
|
+
// exact domain check passed and the exact URI check then refused every single
|
|
303
|
+
// login. Two checks derived from one value have to derive it the same way.
|
|
304
|
+
const authority = env['POG_SIWS_DOMAIN']?.trim() || TRUSTED_SITE_HOST;
|
|
305
|
+
try {
|
|
306
|
+
return [normalizeUri(new URL(`https://${authority}`))];
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
throw new Error(`POG_SIWS_DOMAIN is not an authority a URI can be built from: ${authority}. ` +
|
|
310
|
+
'Set POG_SIWS_URI to the exact URI your deployment signs for.');
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Compare-ready form: scheme and host lowercased, an empty path and a bare "/"
|
|
315
|
+
* treated as the same thing. Path, query and fragment keep their case — those
|
|
316
|
+
* are case-sensitive, and folding them would let two different resources match.
|
|
317
|
+
*/
|
|
318
|
+
function normalizeUri(url) {
|
|
319
|
+
const path = url.pathname === '/' ? '' : url.pathname.replace(/\/+$/, '');
|
|
320
|
+
return `${url.protocol}//${url.host.toLowerCase()}${path}${url.search}${url.hash}`;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* The one chain id this client will sign a sign-in for.
|
|
324
|
+
*
|
|
325
|
+
* The API binds auth to a single chain (SOLANA_DEVNET_CHAIN_ID today), so this
|
|
326
|
+
* is an exact match, not a family. Moving the deployment to another chain means
|
|
327
|
+
* setting POG_SIWS_CHAIN — logins fail closed until then, and the refusal names
|
|
328
|
+
* both chains so the cause is obvious.
|
|
329
|
+
*/
|
|
330
|
+
export function expectedSiwsChain(env = process.env) {
|
|
331
|
+
return env['POG_SIWS_CHAIN']?.trim() ?? 'solana:devnet';
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Refuse to sign anything that is not a login to THIS service, for THIS wallet,
|
|
335
|
+
* with the nonce we just asked for.
|
|
336
|
+
*
|
|
337
|
+
* Every field is bound, not sampled. A message we cannot fully parse is a
|
|
338
|
+
* refusal: there is no safe way to sign text we could not read, and the only
|
|
339
|
+
* party who benefits from a lenient parser here is whoever served the message.
|
|
340
|
+
*/
|
|
341
|
+
export function assertSiwsMatchesRequest(message, expected, env = process.env) {
|
|
342
|
+
const hosts = expectedSiwsHosts(expected.origin, env);
|
|
343
|
+
const fail = (why) => {
|
|
344
|
+
throw new Error(`Refusing to sign: ${why}. The server at ${expected.origin} returned a sign-in message ` +
|
|
345
|
+
`that is not a login to ${hosts.join(' or ')} for this wallet. Check POG_API_URL — and ` +
|
|
346
|
+
'if you are deliberately pointing at a staging or self-hosted deployment, set ' +
|
|
347
|
+
'POG_SIWS_DOMAIN to the domain it signs people in to.');
|
|
348
|
+
};
|
|
349
|
+
// Plaintext to anything but loopback is not a transport detail here.
|
|
350
|
+
//
|
|
351
|
+
// An on-path attacker can proxy the nonce request to the real API, hand back
|
|
352
|
+
// the genuine trusted message — which passes every check below, because it IS
|
|
353
|
+
// genuine — then take the signature and the sign-in response, and walk away
|
|
354
|
+
// with a bearer session for the persistent wallet. Validating the message
|
|
355
|
+
// cannot help when the message is authentic and the channel is not.
|
|
356
|
+
assertTransportIsSafe(expected.origin);
|
|
357
|
+
const siws = parseSiws(message);
|
|
358
|
+
if (siws === null)
|
|
359
|
+
return fail('the message is not a SIWS sign-in request');
|
|
360
|
+
if (!hosts.includes(hostnameOf(siws.domain))) {
|
|
361
|
+
fail(`it signs you in to "${siws.domain}"`);
|
|
362
|
+
}
|
|
363
|
+
// And the domain WHOLE, for the same reason as the URI below: the API compares
|
|
364
|
+
// this field to SIWS_DOMAIN exactly, so `pog.soccer:8443` or
|
|
365
|
+
// `https://pog.soccer` is an authority it will reject — after the persistent
|
|
366
|
+
// wallet has signed an authorization naming it. hostnameOf() exists to make
|
|
367
|
+
// the host check port-insensitive, not to make the field itself loose.
|
|
368
|
+
const expectedDomains = expectedSiwsDomain(expected.origin, env);
|
|
369
|
+
if (expectedDomains !== null && siws.domain !== expectedDomains) {
|
|
370
|
+
fail(`it signs you in to "${String(siws.domain)}", not "${expectedDomains}" — set ` +
|
|
371
|
+
'POG_SIWS_DOMAIN if that is genuinely what your deployment signs for');
|
|
372
|
+
}
|
|
373
|
+
if (siws.address !== expected.walletAddress) {
|
|
374
|
+
fail(`it names wallet "${siws.address}", not ours`);
|
|
375
|
+
}
|
|
376
|
+
if (siws.nonce !== expected.nonce) {
|
|
377
|
+
fail('the nonce is not the one we requested');
|
|
378
|
+
}
|
|
379
|
+
// A signature is scoped to a chain, and the API binds authentication to ONE
|
|
380
|
+
// chain id. Accepting any solana:* left the door open to a signature that
|
|
381
|
+
// authorises a different chain context than the one intended.
|
|
382
|
+
const wantChain = expectedSiwsChain(env);
|
|
383
|
+
if (siws.chainId !== wantChain) {
|
|
384
|
+
fail(`it is for chain "${String(siws.chainId)}", not ${wantChain}`);
|
|
385
|
+
}
|
|
386
|
+
if (siws.uri === null)
|
|
387
|
+
return fail('it has no URI');
|
|
388
|
+
const expectedUris = expectedSiwsUris(expected.origin, env);
|
|
389
|
+
let uri;
|
|
390
|
+
try {
|
|
391
|
+
uri = new URL(siws.uri);
|
|
392
|
+
}
|
|
393
|
+
catch {
|
|
394
|
+
return fail(`its URI is not a URL (${siws.uri})`);
|
|
395
|
+
}
|
|
396
|
+
// The exact URI override names its own host, and that is the whole point of
|
|
397
|
+
// it: a deployment can serve the app at app.example.com while signing for
|
|
398
|
+
// example.com. Checking the URI's host against the DOMAIN list refused the
|
|
399
|
+
// very configuration POG_SIWS_URI exists to express — two checks of mine
|
|
400
|
+
// contradicting each other, with the stricter one winning silently.
|
|
401
|
+
//
|
|
402
|
+
// Only this check widens. The API-origin allowlist and the domain comparison
|
|
403
|
+
// are untouched: an override says where the app lives, not who may serve the
|
|
404
|
+
// message or what authority it may name.
|
|
405
|
+
const uriHosts = expectedUris === null ? hosts : [...hosts, ...expectedUris.map((u) => hostnameOf(u))];
|
|
406
|
+
if (!uriHosts.includes(uri.hostname.toLowerCase())) {
|
|
407
|
+
fail(`its URI points at ${uri.hostname.toLowerCase()}`);
|
|
408
|
+
}
|
|
409
|
+
// The WHOLE URI, not just its host. The API binds this field to one exact
|
|
410
|
+
// string, so `http://pog.soccer/other-app` is a resource it will reject —
|
|
411
|
+
// afterwards, with the signature over a different scheme and path already
|
|
412
|
+
// made by a persistent wallet. Same reason every other field is bound here:
|
|
413
|
+
// the artifact is the thing not to produce.
|
|
414
|
+
if (expectedUris !== null && !expectedUris.includes(normalizeUri(uri))) {
|
|
415
|
+
fail(`its URI is ${siws.uri}, not ${expectedUris.join(' or ')} — set POG_SIWS_URI if that is ` +
|
|
416
|
+
'genuinely what your deployment signs for');
|
|
417
|
+
}
|
|
418
|
+
// Version is part of the signed bytes and decides how a verifier reads them.
|
|
419
|
+
// The API emits "1" and its validator does not reject anything else, so an
|
|
420
|
+
// unsupported version would be signed here and interpreted downstream by
|
|
421
|
+
// whatever rules that version implies.
|
|
422
|
+
if (siws.version !== '1')
|
|
423
|
+
fail(`its version is "${String(siws.version)}"`);
|
|
424
|
+
// Issued At bounds the other end of the window. Without it a message can
|
|
425
|
+
// claim to have been minted at any time; with a far-future one, `Expiration
|
|
426
|
+
// Time` stops meaning anything. Signed first, rejected by the API later — so
|
|
427
|
+
// the wallet has already produced the artifact.
|
|
428
|
+
if (siws.issuedAt === null)
|
|
429
|
+
fail('it has no issue time');
|
|
430
|
+
const issuedAt = Date.parse(siws.issuedAt);
|
|
431
|
+
if (Number.isNaN(issuedAt))
|
|
432
|
+
fail(`its issue time is unreadable (${siws.issuedAt})`);
|
|
433
|
+
if (issuedAt > Date.now() + CLOCK_SKEW_MS)
|
|
434
|
+
fail('it claims to have been issued in the future');
|
|
435
|
+
// And a lower bound. Only rejecting the future let a stale message through —
|
|
436
|
+
// the API refuses it afterwards for an expired nonce, but by then the wallet
|
|
437
|
+
// has already produced the signature, which is the thing worth not producing.
|
|
438
|
+
if (issuedAt < Date.now() - NONCE_TTL_MS - CLOCK_SKEW_MS) {
|
|
439
|
+
fail(`it was issued at ${siws.issuedAt}, too long ago to still be live`);
|
|
440
|
+
}
|
|
441
|
+
// NOTE: the statement line is deliberately not bound. For a headless signer
|
|
442
|
+
// it is not a security boundary the way domain/URI/nonce/chain are — nobody
|
|
443
|
+
// reads it — and pinning it would couple this client to server wording that
|
|
444
|
+
// may legitimately change.
|
|
445
|
+
//
|
|
446
|
+
// REQUIRED, not "checked if present". The API always emits a ten-minute
|
|
447
|
+
// expiry; a message without one is a signature with no time bound, and
|
|
448
|
+
// whoever collected it holds a reusable authorisation forever. Treating
|
|
449
|
+
// absence as "nothing to check" made omission the easiest way past the check.
|
|
450
|
+
if (siws.expirationTime === null)
|
|
451
|
+
fail('it has no expiration');
|
|
452
|
+
const expiresAt = Date.parse(siws.expirationTime);
|
|
453
|
+
if (Number.isNaN(expiresAt))
|
|
454
|
+
fail(`its expiration is unreadable (${siws.expirationTime})`);
|
|
455
|
+
if (expiresAt <= Date.now())
|
|
456
|
+
fail('it has already expired');
|
|
457
|
+
if (expiresAt <= issuedAt)
|
|
458
|
+
fail('it expires before it was issued');
|
|
459
|
+
// Upper bound too. Requiring an expiry without capping it still let a
|
|
460
|
+
// compromised endpoint ask for a signature valid for years — an authorisation
|
|
461
|
+
// artifact with a lifetime of its own choosing. The real message lives for the
|
|
462
|
+
// nonce TTL, so anything longer is not this service's.
|
|
463
|
+
if (expiresAt > issuedAt + NONCE_TTL_MS + CLOCK_SKEW_MS) {
|
|
464
|
+
fail(`it stays valid until ${siws.expirationTime}, far longer than a sign-in should`);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
export class PogClient {
|
|
468
|
+
baseUrl;
|
|
469
|
+
fetchImpl;
|
|
470
|
+
timeoutMs;
|
|
471
|
+
session = null;
|
|
472
|
+
constructor(baseUrl = process.env['POG_API_URL'] ?? DEFAULT_API_URL, fetchImpl = fetch,
|
|
473
|
+
/** Per-request ceiling. Generous: a cup-day bracket is a large response. */
|
|
474
|
+
timeoutMs = Number(process.env['POG_API_TIMEOUT_MS'] ?? 30_000)) {
|
|
475
|
+
this.baseUrl = baseUrl;
|
|
476
|
+
this.fetchImpl = fetchImpl;
|
|
477
|
+
this.timeoutMs = timeoutMs;
|
|
478
|
+
this.baseUrl = this.baseUrl.replace(/\/+$/, '');
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* The active session, or null when there is none or it has expired.
|
|
482
|
+
*
|
|
483
|
+
* Expiry is checked on read rather than trusted from when it was issued: this
|
|
484
|
+
* process can outlive a session, and returning a stale one made `whoami`
|
|
485
|
+
* report "signed in" while every authenticated call 401'd.
|
|
486
|
+
*/
|
|
487
|
+
currentSession() {
|
|
488
|
+
if (this.session === null)
|
|
489
|
+
return null;
|
|
490
|
+
const expiresAt = Date.parse(this.session.expiresAt);
|
|
491
|
+
if (!Number.isNaN(expiresAt) && expiresAt <= Date.now()) {
|
|
492
|
+
this.session = null;
|
|
493
|
+
return null;
|
|
494
|
+
}
|
|
495
|
+
return this.session;
|
|
496
|
+
}
|
|
497
|
+
async request(path, init = {}) {
|
|
498
|
+
const { auth, headers, ...rest } = init;
|
|
499
|
+
const merged = {
|
|
500
|
+
accept: 'application/json',
|
|
501
|
+
...(rest.body ? { 'content-type': 'application/json' } : {}),
|
|
502
|
+
...(headers ?? {}),
|
|
503
|
+
};
|
|
504
|
+
const session = this.currentSession();
|
|
505
|
+
if (auth && !session) {
|
|
506
|
+
throw new ApiError(401, path, 'Not signed in (or the session expired) — call login first');
|
|
507
|
+
}
|
|
508
|
+
// Send the token on PUBLIC reads too, whenever we hold one.
|
|
509
|
+
//
|
|
510
|
+
// The API rate-limits by wallet when a request is authenticated and by IP
|
|
511
|
+
// when it is not (lib/rate-key.ts). Leaving reads anonymous would put every
|
|
512
|
+
// agent on one machine back into a single shared IP bucket for exactly the
|
|
513
|
+
// calls they make most — leaderboard, cup, squad, match. The routes ignore
|
|
514
|
+
// the header; only the limiter reads it.
|
|
515
|
+
if (session)
|
|
516
|
+
merged['authorization'] = `Bearer ${session.sessionId}`;
|
|
517
|
+
// Bound every request. A server that accepts the connection and then stops
|
|
518
|
+
// answering — an unhealthy rollout, a half-open path — would otherwise leave
|
|
519
|
+
// the tool call pending forever, and an agent with no error and no result
|
|
520
|
+
// cannot continue its loop or report anything useful.
|
|
521
|
+
const timeout = AbortSignal.timeout(this.timeoutMs);
|
|
522
|
+
const signal = rest.signal == null
|
|
523
|
+
? timeout
|
|
524
|
+
: // Preserve a caller's own cancellation; whichever fires first wins.
|
|
525
|
+
AbortSignal.any([rest.signal, timeout]);
|
|
526
|
+
let res;
|
|
527
|
+
try {
|
|
528
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, { ...rest, headers: merged, signal });
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
if (timeout.aborted) {
|
|
532
|
+
throw new ApiError(504, path, `No response from ${this.baseUrl} within ${String(this.timeoutMs)}ms`);
|
|
533
|
+
}
|
|
534
|
+
throw err;
|
|
535
|
+
}
|
|
536
|
+
const text = await res.text();
|
|
537
|
+
let body = null;
|
|
538
|
+
try {
|
|
539
|
+
body = text ? JSON.parse(text) : null;
|
|
540
|
+
}
|
|
541
|
+
catch {
|
|
542
|
+
body = text;
|
|
543
|
+
}
|
|
544
|
+
if (!res.ok) {
|
|
545
|
+
// Surface everything the server said, in the order that carries meaning.
|
|
546
|
+
//
|
|
547
|
+
// Fastify's schema layer answers `{ error: "Bad Request", message: "body/
|
|
548
|
+
// players/0 must have required property 'pass'" }` — so preferring `error`
|
|
549
|
+
// throws away the only useful half and hands the agent something it cannot
|
|
550
|
+
// act on. The route's own validator instead answers `{ error: "...",
|
|
551
|
+
// details: [...] }` naming the rule that failed. Keep whichever parts exist.
|
|
552
|
+
const b = body;
|
|
553
|
+
const parts = [b?.error, b?.message].filter((p) => typeof p === 'string' && p.length > 0);
|
|
554
|
+
// Drop the generic HTTP-status echo when a real explanation sits next to it.
|
|
555
|
+
const meaningful = parts.length > 1 ? parts.filter((p) => p !== res.statusText) : parts;
|
|
556
|
+
const detail = (meaningful.length ? meaningful.join(' — ') : null) ??
|
|
557
|
+
(typeof body === 'string' && body ? body : res.statusText);
|
|
558
|
+
// Keep the machine-readable half. The body's own figure wins over the
|
|
559
|
+
// header: the playoff cooldown reports the exact instant the next match is
|
|
560
|
+
// allowed, while `retry-after` only counts down the rate-limit window.
|
|
561
|
+
const header = (name) => typeof res.headers?.get === 'function' ? res.headers.get(name) : null;
|
|
562
|
+
const asNumber = (raw) => {
|
|
563
|
+
if (raw === null)
|
|
564
|
+
return undefined;
|
|
565
|
+
const n = Number(raw);
|
|
566
|
+
return Number.isFinite(n) ? n : undefined;
|
|
567
|
+
};
|
|
568
|
+
const bodySeconds = typeof b?.retryAfterMs === 'number' ? Math.ceil(b.retryAfterMs / 1000) : undefined;
|
|
569
|
+
const extra = {
|
|
570
|
+
...(bodySeconds ?? asNumber(header('retry-after'))) !== undefined
|
|
571
|
+
? { retryAfterSeconds: bodySeconds ?? asNumber(header('retry-after')) }
|
|
572
|
+
: {},
|
|
573
|
+
...(typeof b?.nextMatchAt === 'string' ? { retryAt: b.nextMatchAt } : {}),
|
|
574
|
+
...(asNumber(header('x-ratelimit-remaining')) !== undefined
|
|
575
|
+
? { remaining: asNumber(header('x-ratelimit-remaining')) }
|
|
576
|
+
: {}),
|
|
577
|
+
};
|
|
578
|
+
throw new ApiError(res.status, path, b?.details?.length ? `${detail}: ${JSON.stringify(b.details)}` : detail, b?.teamId, extra);
|
|
579
|
+
}
|
|
580
|
+
return body;
|
|
581
|
+
}
|
|
582
|
+
// -------------------------------------------------------------------------
|
|
583
|
+
// Auth
|
|
584
|
+
// -------------------------------------------------------------------------
|
|
585
|
+
/** Run the full SIWS handshake and hold the resulting bearer session. */
|
|
586
|
+
async login(mnemonic, walletAddress) {
|
|
587
|
+
// Decide whether this endpoint is one we will sign for BEFORE telling it
|
|
588
|
+
// which wallet is asking. Both checks run again inside
|
|
589
|
+
// assertSiwsMatchesRequest — that pass guards the message we were handed,
|
|
590
|
+
// this one guards the handshake, and an unknown host learns nothing.
|
|
591
|
+
const origin = new URL(this.baseUrl).origin;
|
|
592
|
+
assertTransportIsSafe(origin);
|
|
593
|
+
expectedSiwsHosts(origin);
|
|
594
|
+
const { nonce } = await this.request(`/api/auth/nonce?wallet=${encodeURIComponent(walletAddress)}`);
|
|
595
|
+
const { message } = await this.request(`/api/auth/message?wallet=${encodeURIComponent(walletAddress)}&nonce=${encodeURIComponent(nonce)}`);
|
|
596
|
+
// Never sign server text unread. Fetching the message is right — the server
|
|
597
|
+
// owns the exact wording — but signing it unconditionally turns this process
|
|
598
|
+
// into an oracle: point POG_API_URL at a typo'd host or a compromised
|
|
599
|
+
// staging box and it will hand back a valid, reusable signature over
|
|
600
|
+
// whatever that host wants, made with the persistent real wallet key.
|
|
601
|
+
assertSiwsMatchesRequest(message, { walletAddress, nonce, origin });
|
|
602
|
+
const signed = await this.request('/api/auth/signin', {
|
|
603
|
+
method: 'POST',
|
|
604
|
+
body: JSON.stringify({ message, signature: signMessage(mnemonic, message), walletAddress }),
|
|
605
|
+
});
|
|
606
|
+
this.session = signed;
|
|
607
|
+
return signed;
|
|
608
|
+
}
|
|
609
|
+
// -------------------------------------------------------------------------
|
|
610
|
+
// Read
|
|
611
|
+
// -------------------------------------------------------------------------
|
|
612
|
+
/**
|
|
613
|
+
* Liveness. Deliberately /healthz and not /api/ops/status — the ops routes are
|
|
614
|
+
* operator surface and answer 401 without OPS_API_KEY on any real deployment,
|
|
615
|
+
* so exposing them here would ship a tool that never works for a player.
|
|
616
|
+
*/
|
|
617
|
+
health() {
|
|
618
|
+
return this.request('/healthz');
|
|
619
|
+
}
|
|
620
|
+
/** Whether the FA market is open on this deployment (public, ungated). */
|
|
621
|
+
marketStatus() {
|
|
622
|
+
return this.request('/api/market/status');
|
|
623
|
+
}
|
|
624
|
+
nations() {
|
|
625
|
+
return this.request('/api/nations');
|
|
626
|
+
}
|
|
627
|
+
myTeams() {
|
|
628
|
+
return this.request('/api/teams', { auth: true });
|
|
629
|
+
}
|
|
630
|
+
/**
|
|
631
|
+
* Everything about this manager in one authenticated call: squads, league
|
|
632
|
+
* rank, playoff entry and cooldown, finished-match history, next fixture,
|
|
633
|
+
* honours, career record.
|
|
634
|
+
*
|
|
635
|
+
* This is the endpoint that makes the game playable across sessions. Cups run
|
|
636
|
+
* daily and finish hours after they open, so an agent that only ever looks at
|
|
637
|
+
* what it did inside one session never sees its own results — and never has a
|
|
638
|
+
* reason to change the squad. The alternative is six separate calls the agent
|
|
639
|
+
* has to know to make and stitch together itself.
|
|
640
|
+
*/
|
|
641
|
+
dashboard() {
|
|
642
|
+
return this.request('/api/dashboard/me', { auth: true });
|
|
643
|
+
}
|
|
644
|
+
/**
|
|
645
|
+
* A team's finished matches, newest first, up to `limit` (the API caps at 200).
|
|
646
|
+
*
|
|
647
|
+
* Lives under /api/playoff/ but is not playoff-only — it lists every completed
|
|
648
|
+
* match for the team, friendlies included. Needed because the dashboard's own
|
|
649
|
+
* `teamHistory` is capped at five rows, which is fewer than a single cup day
|
|
650
|
+
* produces.
|
|
651
|
+
*/
|
|
652
|
+
teamHistory(teamId, limit) {
|
|
653
|
+
return this.request(`/api/playoff/${encodeURIComponent(teamId)}/history?limit=${String(limit)}`);
|
|
654
|
+
}
|
|
655
|
+
team(teamId) {
|
|
656
|
+
return this.request(`/api/teams/${encodeURIComponent(teamId)}`);
|
|
657
|
+
}
|
|
658
|
+
match(matchId) {
|
|
659
|
+
return this.request(`/api/matches/${encodeURIComponent(matchId)}`);
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Cup SUMMARY only: status, champion, final score, match count. No fixtures —
|
|
663
|
+
* see `cupBracket` for those.
|
|
664
|
+
*/
|
|
665
|
+
cup(date) {
|
|
666
|
+
return this.request(`/api/cups/${encodeURIComponent(date)}`);
|
|
667
|
+
}
|
|
668
|
+
/**
|
|
669
|
+
* The fixtures. A cup date maps to tournament id `wc-${date}`, and the bracket
|
|
670
|
+
* lives on the tournament, not the cup — /api/cups/:date returns a summary
|
|
671
|
+
* with a `matchCount` and no matches at all.
|
|
672
|
+
*/
|
|
673
|
+
cupBracket(date) {
|
|
674
|
+
return this.request(`/api/tournaments/wc-${encodeURIComponent(date)}/bracket`);
|
|
675
|
+
}
|
|
676
|
+
/**
|
|
677
|
+
* The board's top `limit` rows. The API defaults to 100 and caps at
|
|
678
|
+
* MAX_LIMIT=500, so a request without an explicit limit silently truncates on
|
|
679
|
+
* any deployment with more managers than that — and `rows.length` is then the
|
|
680
|
+
* page size, not the population.
|
|
681
|
+
*/
|
|
682
|
+
leaderboard(limit = LEADERBOARD_MAX_LIMIT) {
|
|
683
|
+
return this.request(`/api/leaderboard?limit=${String(limit)}`);
|
|
684
|
+
}
|
|
685
|
+
// -------------------------------------------------------------------------
|
|
686
|
+
// Write
|
|
687
|
+
// -------------------------------------------------------------------------
|
|
688
|
+
/**
|
|
689
|
+
* Play one ranked playoff match. Bodyless POST — the server picks the
|
|
690
|
+
* opponent from the ladder and creates the caller's ladder entry on first
|
|
691
|
+
* call, which is how a new squad enters the competitive season at all.
|
|
692
|
+
*
|
|
693
|
+
* No body and therefore no content-type header: sending
|
|
694
|
+
* `application/json` with an empty body is what earns a 415 here.
|
|
695
|
+
*/
|
|
696
|
+
playPlayoff() {
|
|
697
|
+
return this.request('/api/playoff/play', { method: 'POST', auth: true });
|
|
698
|
+
}
|
|
699
|
+
createTeam(input) {
|
|
700
|
+
return this.request('/api/teams', { method: 'POST', auth: true, body: JSON.stringify(input) });
|
|
701
|
+
}
|
|
702
|
+
updateTeam(teamId, input) {
|
|
703
|
+
return this.request(`/api/teams/${encodeURIComponent(teamId)}`, {
|
|
704
|
+
method: 'PUT',
|
|
705
|
+
auth: true,
|
|
706
|
+
body: JSON.stringify(input),
|
|
707
|
+
});
|
|
708
|
+
}
|
|
709
|
+
/**
|
|
710
|
+
* Play a friendly. Team-id form only, on purpose: the server then reads both
|
|
711
|
+
* squads itself. The inline-squad form exists for engine callers and needs the
|
|
712
|
+
* engine's attribute names (dori/shoo/defe), which differ from the flat shape
|
|
713
|
+
* POST /api/teams takes — a trap worth not exposing to an agent.
|
|
714
|
+
*
|
|
715
|
+
* Authenticated, and the caller must OWN the home team: a friendly is a
|
|
716
|
+
* challenge you start with your squad against an arbitrary opponent. Without
|
|
717
|
+
* that gate anyone could fabricate results onto someone else's public career,
|
|
718
|
+
* so the route fails closed at 401 when no session is attached.
|
|
719
|
+
*/
|
|
720
|
+
playFriendly(input) {
|
|
721
|
+
return this.request('/api/matches/friendly', {
|
|
722
|
+
method: 'POST',
|
|
723
|
+
auth: true,
|
|
724
|
+
body: JSON.stringify(input),
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
//# sourceMappingURL=client.js.map
|