@xgamesfun/xcore 0.2.0 → 0.3.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/README.md +104 -14
- package/commands/loot.mjs +13 -4
- package/commands/plan.mjs +16 -13
- package/commands/publish.mjs +14 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +31 -11
- package/lib/add.mjs +1 -0
- package/lib/client/gamesfun.js +344 -8
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/index.mjs +17 -1
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/platform.mjs +5 -2
- package/lib/economy.mjs +12 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/plans.mjs +13 -4
- package/lib/review.mjs +951 -0
- package/lib/server/contract.mjs +4 -1
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +49 -8
package/lib/review.mjs
ADDED
|
@@ -0,0 +1,951 @@
|
|
|
1
|
+
// `xcore review`: the security review of a brought game, as a cheater would read it. Three parts, and the trade-offs
|
|
2
|
+
// for the person:
|
|
3
|
+
// the economy @xgamesfun/solana's own review of the economy file (`xsolana review --json`): each route that pays,
|
|
4
|
+
// what it trusts, its ceiling, its findings, and `tradeoffs` (words for the person). An installed
|
|
5
|
+
// package without `review` is said in one line, and the rest of the review goes on.
|
|
6
|
+
// the server this package's own static reading of server/ and the page: PAY.check written or still the template,
|
|
7
|
+
// one that pays from the request alone, VOUCHERS.payOnly against the grants, the voucher key and the
|
|
8
|
+
// grants that name it, `trust` on each voucher grant, .dev.vars in git or in the published folder, a
|
|
9
|
+
// seed in the page's files, and every api/ route the code names (for the live part).
|
|
10
|
+
// live (--live) each route asked with no sign-in and with a forged one, GET and POST; api/vouchers/key
|
|
11
|
+
// against the grants' keys; the published economy file's `live` against this folder's. Read-only:
|
|
12
|
+
// nothing it sends could spend or sign (a route that pays gets an empty body).
|
|
13
|
+
// The record goes in <game>/evidence/review-N/ (review.md and review.json), as the page check's goes in verify-N/.
|
|
14
|
+
// docs/SERVER.md, "How much your server can check", is the guide the findings point to.
|
|
15
|
+
import { spawnSync } from 'node:child_process';
|
|
16
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
|
|
17
|
+
import { mkdir, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { CMD } from './command.mjs';
|
|
20
|
+
import { economyFile, solanaCommand, solanaHas, solanaRoot } from './economy.mjs';
|
|
21
|
+
import { pageRoots } from './publish/page.mjs';
|
|
22
|
+
import { isBinaryPath, scanFile } from './publish/scan.mjs';
|
|
23
|
+
import { solanaRpc } from './solana-rpc.mjs';
|
|
24
|
+
|
|
25
|
+
/** How much the server holding a grant's voucher key checks before it signs, strongest first (the grant's `trust`). */
|
|
26
|
+
export const TRUST = ['decides', 'replays', 'bounds'];
|
|
27
|
+
export const SEVERITIES = ['high', 'medium', 'low', 'info'];
|
|
28
|
+
const PACKAGE = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'));
|
|
29
|
+
const GUIDE = 'docs/SERVER.md, "How much your server can check"';
|
|
30
|
+
const readJson = file => {
|
|
31
|
+
try {
|
|
32
|
+
return JSON.parse(readFileSync(file, 'utf8'));
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
const posix = p => p.split(path.sep).join('/');
|
|
38
|
+
|
|
39
|
+
// ---- Where things are -------------------------------------------------------------------------------------------------
|
|
40
|
+
|
|
41
|
+
/** The folder a game's economy lives in, as `xcore loot add` puts it: a brought game's static folder, else its own. */
|
|
42
|
+
export function economyHome(dir) {
|
|
43
|
+
const r = pageRoots(dir);
|
|
44
|
+
return r.static ?? r.out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The grants a voucher pays: those that name at least one voucher key. */
|
|
48
|
+
export const voucherGrants = spec =>
|
|
49
|
+
(Array.isArray(spec?.grants) ? spec.grants : []).filter(g => Array.isArray(g?.vouchers) && g.vouchers.length);
|
|
50
|
+
|
|
51
|
+
/** The test key's seed in .dev.vars (VOUCHER_SEED), or null. */
|
|
52
|
+
function devSeed(dir) {
|
|
53
|
+
try {
|
|
54
|
+
const text = readFileSync(path.join(dir, '.dev.vars'), 'utf8');
|
|
55
|
+
return /^\s*VOUCHER_SEED\s*=\s*["']?([0-9a-fA-F]{64})["']?\s*$/m.exec(text)?.[1].toLowerCase() ?? null;
|
|
56
|
+
} catch {
|
|
57
|
+
return null;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Every file under `root` (no hidden files or folders, no node_modules, evidence or `skip` folders), as posix paths. */
|
|
62
|
+
async function filesUnder(root, { skip = [] } = {}) {
|
|
63
|
+
const out = [];
|
|
64
|
+
async function walk(abs, rel) {
|
|
65
|
+
let entries;
|
|
66
|
+
try {
|
|
67
|
+
entries = await readdir(abs, { withFileTypes: true });
|
|
68
|
+
} catch {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const e of entries) {
|
|
72
|
+
if (e.name.startsWith('.') || e.name === 'node_modules') continue;
|
|
73
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
74
|
+
if (!rel && e.name === 'evidence') continue;
|
|
75
|
+
if (skip.includes(r)) continue;
|
|
76
|
+
if (e.isDirectory()) await walk(path.join(abs, e.name), r);
|
|
77
|
+
else if (e.isFile()) out.push(r);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
await walk(root, '');
|
|
81
|
+
return out.sort();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ---- Reading code -----------------------------------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
/** The code with its comments blanked out (strings kept, so a URL's // stays), lengths and lines unchanged. */
|
|
87
|
+
export function withoutComments(code) {
|
|
88
|
+
let out = '',
|
|
89
|
+
i = 0,
|
|
90
|
+
quote = null;
|
|
91
|
+
while (i < code.length) {
|
|
92
|
+
const c = code[i],
|
|
93
|
+
d = code[i + 1];
|
|
94
|
+
if (quote) {
|
|
95
|
+
out += c;
|
|
96
|
+
if (c === '\\') {
|
|
97
|
+
out += d ?? '';
|
|
98
|
+
i += 2;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (c === quote) quote = null;
|
|
102
|
+
i++;
|
|
103
|
+
} else if (c === '"' || c === "'" || c === '`') {
|
|
104
|
+
quote = c;
|
|
105
|
+
out += c;
|
|
106
|
+
i++;
|
|
107
|
+
} else if (c === '/' && d === '/') {
|
|
108
|
+
while (i < code.length && code[i] !== '\n') ((out += ' '), i++);
|
|
109
|
+
} else if (c === '/' && d === '*') {
|
|
110
|
+
const end = code.indexOf('*/', i + 2);
|
|
111
|
+
const stop = end < 0 ? code.length : end + 2;
|
|
112
|
+
out += code.slice(i, stop).replace(/[^\n]/g, ' ');
|
|
113
|
+
i = stop;
|
|
114
|
+
} else {
|
|
115
|
+
out += c;
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** The `{…}` that opens at `at` in comment-free code: its text, or null. */
|
|
123
|
+
function braced(code, at) {
|
|
124
|
+
let depth = 0;
|
|
125
|
+
for (let i = at; i < code.length; i++) {
|
|
126
|
+
if (code[i] === '{') depth++;
|
|
127
|
+
else if (code[i] === '}' && --depth === 0) return code.slice(at, i + 1);
|
|
128
|
+
}
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* What server/vouchers.js says about paying: whether PAY.check is still the template, its parameters and body, and
|
|
134
|
+
* VOUCHERS.payOnly. → {template, check: {params, body} | null, payOnly}
|
|
135
|
+
*/
|
|
136
|
+
export function readVouchers(text) {
|
|
137
|
+
const code = withoutComments(text);
|
|
138
|
+
const pay = /^export const PAY = \{/m.exec(code);
|
|
139
|
+
const block = pay ? braced(code, pay.index + pay[0].length - 1) : null;
|
|
140
|
+
let check = null;
|
|
141
|
+
if (block) {
|
|
142
|
+
const m = /\bcheck\s*(?::\s*(?:async\s*)?(?:function\s*)?)?\(([^)]*)\)\s*(?:=>\s*)?\{/.exec(block);
|
|
143
|
+
if (m) check = { params: m[1], body: braced(block, m.index + m[0].length - 1) ?? '' };
|
|
144
|
+
}
|
|
145
|
+
const vouchers = /^export const VOUCHERS = \{/m.exec(code);
|
|
146
|
+
const settings = vouchers ? braced(code, vouchers.index + vouchers[0].length - 1) : '';
|
|
147
|
+
return {
|
|
148
|
+
template: /This game pays no results yet/.test(text),
|
|
149
|
+
check,
|
|
150
|
+
payOnly: /\bpayOnly\s*:\s*['"](\w+)['"]/.exec(settings ?? '')?.[1] ?? null,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Does PAY.check pay from the request alone? It answers a grant, and never reads `env` (so no Durable Object, no
|
|
156
|
+
* database, no record of a run the server started) nor fetches anything: whatever it pays, the page's request decided.
|
|
157
|
+
* A Worker has no state but what env gives it, so this is a reading, not a guess; a check that reads state any other way
|
|
158
|
+
* is not flagged.
|
|
159
|
+
*/
|
|
160
|
+
export function paysFromRequest(check) {
|
|
161
|
+
if (!check?.body) return false;
|
|
162
|
+
const b = check.body;
|
|
163
|
+
return /\bgrant\s*:/.test(b) && !/\benv\b/.test(b) && !/\bfetch\s*\(/.test(b) && !/\bthis\b/.test(b);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** A regex's or a string's text from `api/` on, as the paths it names: `(a|b)` spread, `\/?$` and the like dropped. */
|
|
167
|
+
function routesIn(fragment) {
|
|
168
|
+
const text = fragment.replace(/\\\//g, '/').replace(/\/\?\$?$|\$$/, '');
|
|
169
|
+
const group = /\(\?:?([\w|-]+)\)|\(([\w|-]+)\)/.exec(text);
|
|
170
|
+
const spread = group
|
|
171
|
+
? (group[1] ?? group[2])
|
|
172
|
+
.split('|')
|
|
173
|
+
.map(w => text.slice(0, group.index) + w + text.slice(group.index + group[0].length))
|
|
174
|
+
: [text];
|
|
175
|
+
return spread
|
|
176
|
+
.map(r => r.replace(/[/?$]+$/, ''))
|
|
177
|
+
.filter(r => /^api(\/[\w-]+)+$/.test(r) || r === 'api')
|
|
178
|
+
.filter(r => r !== 'api');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The api/ routes a file's code names: in strings, template literals (up to the first `${`) and regex literals
|
|
183
|
+
* (`/\/api\/tide\/(start|found)\/?$/`), and the page's `gf.api('vouchers/pay')`. Comments are left out. → [path]
|
|
184
|
+
*/
|
|
185
|
+
export function apiRoutes(code) {
|
|
186
|
+
const c = withoutComments(code);
|
|
187
|
+
const out = new Set();
|
|
188
|
+
for (const m of c.matchAll(/(?:\\?\/|['"`])(api(?:\\?\/[\w|()?:-]+)+(?:\\?\/)?\??\$?)/g))
|
|
189
|
+
for (const r of routesIn(m[1].replace(/\$\{.*$/, ''))) out.add(r);
|
|
190
|
+
for (const m of c.matchAll(/\.api\(\s*['"`]([\w-]+(?:\/[\w-]+)*)/g)) out.add(`api/${m[1]}`);
|
|
191
|
+
return [...out];
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// ---- The economy's own review -----------------------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
/** What the installed @xgamesfun/solana is: {version, root} or null. */
|
|
197
|
+
function solanaPackage(env) {
|
|
198
|
+
const root = solanaRoot(env);
|
|
199
|
+
if (!root) return null;
|
|
200
|
+
return { root, version: readJson(path.join(root, 'package.json'))?.version ?? null };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const NO_REVIEW =
|
|
204
|
+
'the economy review needs @xgamesfun/solana with `review` (xsolana review): update @xgamesfun/xcore once it is released, or set GAMESFUN_SOLANA to a solana-kit checkout that has it';
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* @xgamesfun/solana's review of the economy file: `xsolana review <file> --json`, run in the file's folder.
|
|
208
|
+
* → {available: true, version, ok, routes, findings, tradeoffs} or {available: false, version, why}
|
|
209
|
+
*/
|
|
210
|
+
export function economyReview(file, { env = process.env } = {}) {
|
|
211
|
+
const pkg = solanaPackage(env);
|
|
212
|
+
const xsolana = solanaCommand(env);
|
|
213
|
+
if (!pkg || !xsolana)
|
|
214
|
+
return { available: false, version: null, why: 'the economy tools (@xgamesfun/solana) are not installed here' };
|
|
215
|
+
if (!solanaHas('review', env)) return { available: false, version: pkg.version, why: NO_REVIEW };
|
|
216
|
+
const r = spawnSync(xsolana[0], [xsolana[1], 'review', path.basename(file), '--json'], {
|
|
217
|
+
cwd: path.dirname(file),
|
|
218
|
+
encoding: 'utf8',
|
|
219
|
+
timeout: 120_000,
|
|
220
|
+
env: { ...env, XSOLANA_RPC_URL: env.XSOLANA_RPC_URL?.trim() || solanaRpc(env).url },
|
|
221
|
+
});
|
|
222
|
+
const said = `${r.stdout ?? ''}\n${r.stderr ?? ''}`;
|
|
223
|
+
if (/no command\W+review/i.test(said)) return { available: false, version: pkg.version, why: NO_REVIEW };
|
|
224
|
+
let json = null;
|
|
225
|
+
try {
|
|
226
|
+
json = JSON.parse(r.stdout);
|
|
227
|
+
} catch {}
|
|
228
|
+
if (!json || typeof json !== 'object')
|
|
229
|
+
return {
|
|
230
|
+
available: false,
|
|
231
|
+
version: pkg.version,
|
|
232
|
+
why: `xsolana review gave no JSON (${said.trim().split('\n')[0] || `exit code ${r.status}`})`,
|
|
233
|
+
};
|
|
234
|
+
if (!Array.isArray(json.findings))
|
|
235
|
+
return {
|
|
236
|
+
available: false,
|
|
237
|
+
version: pkg.version,
|
|
238
|
+
why: `xsolana review could not read the economy: ${json.error ?? json.message ?? JSON.stringify(json).slice(0, 200)}`,
|
|
239
|
+
};
|
|
240
|
+
return {
|
|
241
|
+
available: true,
|
|
242
|
+
version: pkg.version,
|
|
243
|
+
ok: json.ok !== false,
|
|
244
|
+
routes: Array.isArray(json.routes) ? json.routes : [],
|
|
245
|
+
findings: json.findings,
|
|
246
|
+
tradeoffs: typeof json.tradeoffs === 'string' ? json.tradeoffs : '',
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ---- The server, read where it is ---------------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
const finding = (source, id, severity, message, fix, route = null) => ({
|
|
253
|
+
source,
|
|
254
|
+
id,
|
|
255
|
+
severity,
|
|
256
|
+
...(route ? { route } : {}),
|
|
257
|
+
message,
|
|
258
|
+
fix,
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
/** Is `rel` tracked by git in `dir` / left out by its .gitignore? → {git: false} or {git: true, tracked, ignored} */
|
|
262
|
+
function gitState(dir, rel) {
|
|
263
|
+
const git = args => spawnSync('git', ['-C', dir, ...args], { encoding: 'utf8', timeout: 10_000 });
|
|
264
|
+
const inside = git(['rev-parse', '--is-inside-work-tree']);
|
|
265
|
+
if (inside.error || inside.status !== 0) return { git: false };
|
|
266
|
+
return {
|
|
267
|
+
git: true,
|
|
268
|
+
tracked: git(['ls-files', '--error-unmatch', rel]).status === 0,
|
|
269
|
+
ignored: git(['check-ignore', '-q', rel]).status === 0,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const TEXT_CODE = /\.(m?js|cjs|jsx|tsx?|html?|svelte|vue|astro)$/i;
|
|
274
|
+
const OWN_COPIES = ['gf', 'gamesfun.js']; // xcore's and loot's copies in the page: not the game's own routes
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The server's part: facts, findings and routes. `spec`: the economy (or null); `economy`: its file; `addressOf(seed)`:
|
|
278
|
+
* a seed's address (the test key's), or null when it cannot be had.
|
|
279
|
+
*/
|
|
280
|
+
export async function serverReview(dir, { spec = null, economy = null, available = false, addressOf = null } = {}) {
|
|
281
|
+
const roots = pageRoots(dir);
|
|
282
|
+
const findings = [];
|
|
283
|
+
const add = (...a) => findings.push(finding('server', ...a));
|
|
284
|
+
const grants = voucherGrants(spec);
|
|
285
|
+
const facts = { server: existsSync(path.join(dir, 'server')), vouchers: false, pay: null, payOnly: null };
|
|
286
|
+
const routes = new Map(); // path → [files that name it]
|
|
287
|
+
const named = (r, file) => routes.set(r, [...new Set([...(routes.get(r) ?? []), file])]);
|
|
288
|
+
|
|
289
|
+
// server/: its routes, and how it pays
|
|
290
|
+
const serverFiles = (await filesUnder(path.join(dir, 'server'), { skip: ['gf-loot'] })).filter(f =>
|
|
291
|
+
TEXT_CODE.test(f),
|
|
292
|
+
);
|
|
293
|
+
let serverCode = '';
|
|
294
|
+
for (const f of serverFiles) {
|
|
295
|
+
const text = await readFile(path.join(dir, 'server', f), 'utf8');
|
|
296
|
+
serverCode += `\n${withoutComments(text)}`;
|
|
297
|
+
for (const r of apiRoutes(text)) named(r, `server/${f}`);
|
|
298
|
+
}
|
|
299
|
+
// A token checked by hand (a JWT library against <play host>/player/v1/key): every game's tokens are signed with the
|
|
300
|
+
// one key, so a check that skips the audience takes a token any other game was given (a game a player signed in to,
|
|
301
|
+
// whose creator then replays the token here). env.GF.verifyPlayer checks it; a hand-made check must name it.
|
|
302
|
+
if (
|
|
303
|
+
/player\/v1\/key|createRemoteJWKSet|jwtVerify\s*\(/.test(serverCode) &&
|
|
304
|
+
!/\baudience\b|\baud\b|game:/.test(serverCode)
|
|
305
|
+
)
|
|
306
|
+
add(
|
|
307
|
+
'token-any-game',
|
|
308
|
+
'high',
|
|
309
|
+
"The server checks players' tokens itself (xgames.fun's key) but never their audience: every game's tokens are signed with one key, so it takes a token another game was given, and that game's maker can act as any of its players here.",
|
|
310
|
+
"Use env.GF.verifyPlayer(token), which checks it, or pass {issuer: 'xgames.fun', audience: 'game:<this game>'} to jwtVerify.",
|
|
311
|
+
);
|
|
312
|
+
const vouchersFile = path.join(dir, 'server', 'vouchers.js');
|
|
313
|
+
const keeper = /\bloot\s*\.\s*report\s*\(/.test(serverCode);
|
|
314
|
+
if (existsSync(vouchersFile)) {
|
|
315
|
+
facts.vouchers = true;
|
|
316
|
+
const v = readVouchers(await readFile(vouchersFile, 'utf8'));
|
|
317
|
+
facts.pay = v.template ? 'template' : v.check ? 'written' : 'unreadable';
|
|
318
|
+
facts.payOnly = v.payOnly;
|
|
319
|
+
if (v.template)
|
|
320
|
+
add(
|
|
321
|
+
'pay-unwritten',
|
|
322
|
+
'info',
|
|
323
|
+
'PAY.check in server/vouchers.js is still the template: the server pays no result yet.',
|
|
324
|
+
`Write PAY.check with the person: what the server checks before a result pays (${GUIDE}).`,
|
|
325
|
+
);
|
|
326
|
+
else if (paysFromRequest(v.check)) {
|
|
327
|
+
const said = [...new Set(grants.map(g => g.trust).filter(Boolean))];
|
|
328
|
+
// with no body read, it pays every signed-in player who asks (a daily gift, say): by design, perhaps, but nothing
|
|
329
|
+
// they did is checked; with the body read, the page's own words decide what is paid
|
|
330
|
+
const body = /\bbody\b/.test(v.check.body);
|
|
331
|
+
add(
|
|
332
|
+
'pay-from-request',
|
|
333
|
+
body ? 'high' : 'medium',
|
|
334
|
+
`PAY.check pays from the ${body ? "page's request" : 'sign-in'} alone: it never reads env (no record of a run the server started, no clock, no replay), so ${body ? 'a script that sends the request is paid' : 'every signed-in player who asks is paid, whatever they did'}.${said.length ? ` The grants declare trust "${said.join('", "')}", which this check does not do.` : ''}`,
|
|
335
|
+
`Check the result against what the server itself keeps (a run it started and timed, a replay, a match it ran), and declare that level as the grant's trust (${GUIDE}).`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
if (!grants.length && spec)
|
|
339
|
+
add(
|
|
340
|
+
'vouchers-unnamed',
|
|
341
|
+
'medium',
|
|
342
|
+
'server/vouchers.js signs vouchers, but no grant in the economy names a voucher key: nothing it signs can be claimed.',
|
|
343
|
+
`Put the server's key in the grant that pays results: "vouchers": ["<the address ${CMD} secret set VOUCHER_SEED --new-key printed>"].`,
|
|
344
|
+
);
|
|
345
|
+
const perPerson = grants.filter(g => Number(g.limits?.perPerson ?? 0) > 0);
|
|
346
|
+
const farmable = grants.filter(g => !(Number(g.limits?.perPerson ?? 0) > 0));
|
|
347
|
+
if (v.payOnly === 'any' && perPerson.length)
|
|
348
|
+
add(
|
|
349
|
+
'payonly-any-perperson',
|
|
350
|
+
'medium',
|
|
351
|
+
`VOUCHERS.payOnly is 'any', but ${perPerson.map(g => `"${g.key}"`).join(', ')} count${perPerson.length === 1 ? 's' : ''} people (perPerson): a player signed in with a wallet only gets past payOnly and is then refused (person), so their claims fail.`,
|
|
352
|
+
"Set VOUCHERS.payOnly to 'x' in server/vouchers.js, so the page asks them to sign in with X first.",
|
|
353
|
+
perPerson.map(g => `grant:${g.key}`).join(', '),
|
|
354
|
+
);
|
|
355
|
+
for (const g of v.payOnly === 'any' ? farmable : []) {
|
|
356
|
+
const capped = Number(g.limits?.perDay ?? 0) > 0 || Number(g.limits?.total ?? 0) > 0;
|
|
357
|
+
add(
|
|
358
|
+
'payonly-any-farmable',
|
|
359
|
+
capped ? 'medium' : 'high',
|
|
360
|
+
`"${g.key}" pays any signed-in player (VOUCHERS.payOnly 'any') and does not count people (no perPerson): a wallet is free, so one person claims it again from every wallet they make${capped ? ', up to its perDay or total' : ', with no total to stop them'}.`,
|
|
361
|
+
`For a reward worth farming: VOUCHERS.payOnly 'x' and "perPerson" in the grant's limits; and a perDay total on every grant.`,
|
|
362
|
+
`grant:${g.key}`,
|
|
363
|
+
);
|
|
364
|
+
}
|
|
365
|
+
} else if (grants.length && !keeper && !/\bsignVoucher\s*\(/.test(serverCode))
|
|
366
|
+
add(
|
|
367
|
+
'vouchers-no-signer',
|
|
368
|
+
'medium',
|
|
369
|
+
`The economy's grants name voucher keys (${grants.map(g => `"${g.key}"`).join(', ')}), but nothing in this game signs a voucher: no server/vouchers.js, no env.GF.loot.report (the keeper), no signVoucher.`,
|
|
370
|
+
`Give the server its key (${CMD} add vouchers) or report results to the keeper (docs/SERVER.md, "Paying a result"); a key held by a backend of your own is fine: say so to the person.`,
|
|
371
|
+
);
|
|
372
|
+
|
|
373
|
+
// trust, on each grant a voucher pays (the economy's own review says it when it can)
|
|
374
|
+
for (const g of grants) {
|
|
375
|
+
if (g.trust === undefined) {
|
|
376
|
+
add(
|
|
377
|
+
'trust-undeclared',
|
|
378
|
+
available ? 'medium' : 'info',
|
|
379
|
+
`"${g.key}" pays with a voucher and does not say how much its server checks before signing ("trust").`,
|
|
380
|
+
available
|
|
381
|
+
? `Declare it honestly, as PAY.check really does it: "trust": "decides", "replays" or "bounds" (${GUIDE}).`
|
|
382
|
+
: `Once the installed @xgamesfun/solana takes it (the one with xsolana review; 0.25.0 refuses the field), declare "trust": "decides", "replays" or "bounds" as PAY.check really does it (${GUIDE}).`,
|
|
383
|
+
`grant:${g.key}`,
|
|
384
|
+
);
|
|
385
|
+
} else if (!TRUST.includes(g.trust))
|
|
386
|
+
add(
|
|
387
|
+
'trust-unknown',
|
|
388
|
+
'medium',
|
|
389
|
+
`"${g.key}" declares trust "${g.trust}", which is none of ${TRUST.join(', ')}.`,
|
|
390
|
+
`Use one of "decides", "replays" or "bounds" (${GUIDE}).`,
|
|
391
|
+
`grant:${g.key}`,
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// .dev.vars: the test key's seed
|
|
396
|
+
const seed = devSeed(dir);
|
|
397
|
+
if (existsSync(path.join(dir, '.dev.vars'))) {
|
|
398
|
+
const g = gitState(dir, '.dev.vars');
|
|
399
|
+
if (g.git && g.tracked)
|
|
400
|
+
add(
|
|
401
|
+
'dev-vars-tracked',
|
|
402
|
+
'high',
|
|
403
|
+
".dev.vars is tracked by git: its secrets (the test voucher key's seed) are in the repository's history, and in every copy of it.",
|
|
404
|
+
`git rm --cached .dev.vars, add .dev.vars to .gitignore, and make new keys: delete VOUCHER_SEED from .dev.vars and run ${CMD} add vouchers again (and take the old test key out of every grant).`,
|
|
405
|
+
);
|
|
406
|
+
else if (g.git && !g.ignored)
|
|
407
|
+
add(
|
|
408
|
+
'dev-vars-not-ignored',
|
|
409
|
+
'low',
|
|
410
|
+
'.dev.vars is not in .gitignore: the next `git add .` commits its secrets.',
|
|
411
|
+
'Add .dev.vars to .gitignore.',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
for (const folder of [...new Set([roots.out, roots.static, roots.overlay].filter(Boolean))])
|
|
415
|
+
if (folder !== path.resolve(dir) && existsSync(path.join(folder, '.dev.vars')))
|
|
416
|
+
add(
|
|
417
|
+
'dev-vars-published-folder',
|
|
418
|
+
'medium',
|
|
419
|
+
`${posix(path.relative(dir, path.join(folder, '.dev.vars')))} is inside the folder the page is served from: xgames.fun leaves hidden files out, but any other host that serves the folder serves it.`,
|
|
420
|
+
'Keep .dev.vars at the top of the game folder only, beside gamesfun.json.',
|
|
421
|
+
);
|
|
422
|
+
if (seed && addressOf) {
|
|
423
|
+
const test = await addressOf(seed).catch(() => null);
|
|
424
|
+
const using = test ? grants.filter(g => g.vouchers.includes(test)) : [];
|
|
425
|
+
facts.testKey = test;
|
|
426
|
+
if (using.length)
|
|
427
|
+
add(
|
|
428
|
+
'test-key-in-grant',
|
|
429
|
+
'medium',
|
|
430
|
+
`${using.map(g => `"${g.key}"`).join(', ')} name${using.length === 1 ? 's' : ''} the test key (its seed is in .dev.vars): anyone with a copy of this folder signs claims for ${using.length === 1 ? 'it' : 'them'}.`,
|
|
431
|
+
'Take the test key out of the grant before the economy holds anything of value; keep only the live key, which only xgames.fun holds.',
|
|
432
|
+
using.map(g => `grant:${g.key}`).join(', '),
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// the page's files: a seed, a key, the test key's own seed; and the routes the page calls
|
|
437
|
+
const pageFiles = await filesUnder(dir, { skip: ['server'] });
|
|
438
|
+
const hits = [];
|
|
439
|
+
for (const f of pageFiles) {
|
|
440
|
+
const full = path.join(dir, f);
|
|
441
|
+
if (isBinaryPath(f) || statSync(full).size > 8 * 1048576) continue;
|
|
442
|
+
const bytes = new Uint8Array(await readFile(full));
|
|
443
|
+
hits.push(...scanFile(f, bytes));
|
|
444
|
+
const text = new TextDecoder().decode(bytes);
|
|
445
|
+
if (seed && text.toLowerCase().includes(seed)) hits.push({ path: f, line: 0, kind: "the test voucher key's seed" });
|
|
446
|
+
else {
|
|
447
|
+
const m = /\bseed\w*["']?\s{0,20}[:=]\s{0,20}["'`][0-9a-fA-F]{64}["'`]/i.exec(text);
|
|
448
|
+
if (m)
|
|
449
|
+
hits.push({ path: f, line: text.slice(0, m.index).split('\n').length, kind: 'a seed (64 hex characters)' });
|
|
450
|
+
}
|
|
451
|
+
const top = f.split('/');
|
|
452
|
+
const own = OWN_COPIES.some(c => top.includes(c));
|
|
453
|
+
if (TEXT_CODE.test(f) && !own) for (const r of apiRoutes(text)) named(r, f);
|
|
454
|
+
}
|
|
455
|
+
const seen = new Set();
|
|
456
|
+
for (const h of hits) {
|
|
457
|
+
if (seen.has(`${h.path} ${h.kind}`)) continue;
|
|
458
|
+
seen.add(`${h.path} ${h.kind}`);
|
|
459
|
+
add(
|
|
460
|
+
'secret-in-page',
|
|
461
|
+
'high',
|
|
462
|
+
`${h.path}${h.line ? `:${h.line}` : ''} holds ${/^an? |^the /.test(h.kind) ? h.kind : `a ${h.kind}`}: the page's files are public, and a key there signs for anyone.`,
|
|
463
|
+
`Take it out of the page: a voucher key lives only on the server (${CMD} secret set VOUCHER_SEED --new-key), and one that was ever in the page is a new key (take the old one out of every grant).`,
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
facts.economy = economy ? posix(path.relative(dir, economy)) : null;
|
|
467
|
+
return {
|
|
468
|
+
facts,
|
|
469
|
+
findings,
|
|
470
|
+
routes: [...routes].sort(([a], [b]) => (a < b ? -1 : 1)).map(([p, from]) => ({ path: p, from })),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ---- Live: the published game, asked as a stranger --------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
/** A sign-in token in the shape the play host's are, signed by nobody. */
|
|
477
|
+
const FORGED = (() => {
|
|
478
|
+
const b64 = v => Buffer.from(JSON.stringify(v)).toString('base64url');
|
|
479
|
+
return `${b64({ alg: 'EdDSA', typ: 'JWT' })}.${b64({ sub: 'review-forged', exp: 4102444800 })}.${'A'.repeat(86)}`;
|
|
480
|
+
})();
|
|
481
|
+
/** Routes that pay, sign or spend: asked with an empty body only, so even an open one has nothing to act on. */
|
|
482
|
+
const ACTS = /\/(pay|claim|sign|link|mint|buy|spend|withdraw|transfer|report|redeem|grant|voucher)s?(\/|$)/i;
|
|
483
|
+
/** Routes every game may answer anyone: the voucher key's address, whether it has its own RPC. */
|
|
484
|
+
const PUBLIC = new Set(['api/vouchers/key', 'api/rpc']);
|
|
485
|
+
|
|
486
|
+
/** One request: → {status, ok (the JSON's ok, or null), said (its why or error, short)} */
|
|
487
|
+
async function ask(fetchImpl, url, { method, auth, body, timeoutMs }) {
|
|
488
|
+
try {
|
|
489
|
+
const res = await fetchImpl(url, {
|
|
490
|
+
method,
|
|
491
|
+
redirect: 'manual',
|
|
492
|
+
headers: {
|
|
493
|
+
accept: 'application/json',
|
|
494
|
+
...(auth ? { authorization: `Bearer ${FORGED}` } : {}),
|
|
495
|
+
...(body !== undefined ? { 'content-type': 'application/json' } : {}),
|
|
496
|
+
},
|
|
497
|
+
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
|
|
498
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
499
|
+
});
|
|
500
|
+
const text = await res.text();
|
|
501
|
+
let json = null;
|
|
502
|
+
try {
|
|
503
|
+
json = JSON.parse(text);
|
|
504
|
+
} catch {}
|
|
505
|
+
const said = json?.why ?? json?.error ?? json?.code ?? (json ? null : text.trim().slice(0, 80) || null);
|
|
506
|
+
return {
|
|
507
|
+
status: res.status,
|
|
508
|
+
ok: json && typeof json === 'object' && 'ok' in json ? json.ok === true : null,
|
|
509
|
+
said: said == null ? null : String(said).slice(0, 120),
|
|
510
|
+
};
|
|
511
|
+
} catch (e) {
|
|
512
|
+
return { status: 0, ok: null, said: e.name === 'TimeoutError' ? 'no answer in time' : e.message };
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const accepted = a => a.status >= 200 && a.status < 300 && a.ok === true;
|
|
517
|
+
|
|
518
|
+
/**
|
|
519
|
+
* The game at `base` (its play origin, or xcore dev's /game/), asked as a stranger. routes: [{path}]; keys: the voucher
|
|
520
|
+
* keys the grants name; testKey: the test key's address; economyPath: the economy file's path in the page; localLive:
|
|
521
|
+
* this folder's economy `live`. → {base, probes, key, economy, findings}
|
|
522
|
+
*/
|
|
523
|
+
export async function liveReview(
|
|
524
|
+
base,
|
|
525
|
+
{
|
|
526
|
+
routes = [],
|
|
527
|
+
keys = [],
|
|
528
|
+
testKey = null,
|
|
529
|
+
economyPath = null,
|
|
530
|
+
localLive = null,
|
|
531
|
+
vouchers = false,
|
|
532
|
+
fetchImpl = fetch,
|
|
533
|
+
timeoutMs = 10_000,
|
|
534
|
+
} = {},
|
|
535
|
+
) {
|
|
536
|
+
const root = base.endsWith('/') ? base : `${base}/`;
|
|
537
|
+
const findings = [];
|
|
538
|
+
const add = (...a) => findings.push(finding('live', ...a));
|
|
539
|
+
const probes = [];
|
|
540
|
+
for (const { path: p } of routes) {
|
|
541
|
+
const url = new URL(p, root).href;
|
|
542
|
+
const body = ACTS.test(p) ? {} : { runId: 'review-probe', id: 'review-probe', seq: 1, score: 1 };
|
|
543
|
+
const asked = {};
|
|
544
|
+
for (const [method, auth] of [
|
|
545
|
+
['GET', false],
|
|
546
|
+
['GET', true],
|
|
547
|
+
['POST', false],
|
|
548
|
+
['POST', true],
|
|
549
|
+
]) {
|
|
550
|
+
const a = await ask(fetchImpl, url, { method, auth, body: method === 'POST' ? body : undefined, timeoutMs });
|
|
551
|
+
asked[`${method} ${auth ? 'forged' : 'none'}`] = a;
|
|
552
|
+
probes.push({ route: p, method, auth: auth ? 'forged' : 'none', ...a });
|
|
553
|
+
}
|
|
554
|
+
const posted = ['POST none', 'POST forged'].filter(k => accepted(asked[k]));
|
|
555
|
+
if (posted.length)
|
|
556
|
+
add(
|
|
557
|
+
'live-open-post',
|
|
558
|
+
'high',
|
|
559
|
+
`POST ${p} answered ok to a request with ${posted.map(k => (k.endsWith('forged') ? 'a forged sign-in' : 'no sign-in')).join(' and with ')}: anyone can do what it does.`,
|
|
560
|
+
'Verify the player first in that route (env.GF.verifyPlayer(token), and refuse when it is not ok), before anything it reads or writes.',
|
|
561
|
+
p,
|
|
562
|
+
);
|
|
563
|
+
if (accepted(asked['GET forged']) && !accepted(asked['GET none']))
|
|
564
|
+
add(
|
|
565
|
+
'live-forged-token',
|
|
566
|
+
'high',
|
|
567
|
+
`GET ${p} refuses no sign-in but answers ok to a forged one: it reads the token without checking its signature.`,
|
|
568
|
+
'Verify the token with env.GF.verifyPlayer(token) and use the player it answers, never what the token says by itself.',
|
|
569
|
+
p,
|
|
570
|
+
);
|
|
571
|
+
else if (accepted(asked['GET none']) && !PUBLIC.has(p))
|
|
572
|
+
add(
|
|
573
|
+
'live-open-get',
|
|
574
|
+
'medium',
|
|
575
|
+
`GET ${p} answers ok to anyone, with no sign-in: fine for what is public (a leaderboard), never for a player's own data or a step of a run.`,
|
|
576
|
+
'If it is not public, verify the player in that route first.',
|
|
577
|
+
p,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
let key = null;
|
|
582
|
+
if (vouchers || routes.some(r => r.path === 'api/vouchers/key')) {
|
|
583
|
+
const url = new URL('api/vouchers/key', root).href;
|
|
584
|
+
try {
|
|
585
|
+
const res = await fetchImpl(url, {
|
|
586
|
+
headers: { accept: 'application/json' },
|
|
587
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
588
|
+
});
|
|
589
|
+
const json = await res.json().catch(() => null);
|
|
590
|
+
key = { status: res.status, publicKey: json?.ok ? (json.publicKey ?? null) : null, said: json?.why ?? null };
|
|
591
|
+
} catch (e) {
|
|
592
|
+
key = { status: 0, publicKey: null, said: e.message };
|
|
593
|
+
}
|
|
594
|
+
if (!key.publicKey)
|
|
595
|
+
add(
|
|
596
|
+
'live-key-unset',
|
|
597
|
+
'medium',
|
|
598
|
+
`api/vouchers/key gives no key (${key.status}${key.said ? `: ${key.said}` : ''}): the live server signs no voucher.`,
|
|
599
|
+
`${CMD} secret set VOUCHER_SEED --new-key, then put the address it prints in the grant.`,
|
|
600
|
+
);
|
|
601
|
+
else if (testKey && key.publicKey === testKey)
|
|
602
|
+
add(
|
|
603
|
+
'live-test-key',
|
|
604
|
+
'high',
|
|
605
|
+
"The live server signs with the test key, whose seed is in this folder's .dev.vars: anyone with a copy of the folder signs what it signs.",
|
|
606
|
+
`Make the live key (${CMD} secret set VOUCHER_SEED --new-key: made and sent to xgames.fun only), put it in the grant in place of the test key, approve and publish.`,
|
|
607
|
+
);
|
|
608
|
+
else if (!keys.includes(key.publicKey))
|
|
609
|
+
add(
|
|
610
|
+
'live-key-unnamed',
|
|
611
|
+
'medium',
|
|
612
|
+
`The live server's key (${key.publicKey}) is in no grant's "vouchers": every voucher it signs is refused on chain.`,
|
|
613
|
+
`Put that address in the grant that pays results, then ${CMD} loot approve and ${CMD} publish.`,
|
|
614
|
+
);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
let economy = null;
|
|
618
|
+
if (economyPath) {
|
|
619
|
+
const url = new URL(economyPath, root).href;
|
|
620
|
+
try {
|
|
621
|
+
const res = await fetchImpl(url, {
|
|
622
|
+
headers: { accept: 'application/json' },
|
|
623
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
624
|
+
});
|
|
625
|
+
const json = res.ok ? await res.json().catch(() => null) : null;
|
|
626
|
+
const same = (a, b) =>
|
|
627
|
+
(a?.project ?? null) === (b?.project ?? null) && (a?.program ?? null) === (b?.program ?? null);
|
|
628
|
+
economy = {
|
|
629
|
+
path: economyPath,
|
|
630
|
+
status: res.status,
|
|
631
|
+
live: json?.live ? { program: json.live.program ?? null, project: json.live.project ?? null } : null,
|
|
632
|
+
same: json ? same(json.live, localLive) : false,
|
|
633
|
+
};
|
|
634
|
+
} catch (e) {
|
|
635
|
+
economy = { path: economyPath, status: 0, live: null, same: false, said: e.message };
|
|
636
|
+
}
|
|
637
|
+
if (!economy.same)
|
|
638
|
+
add(
|
|
639
|
+
'live-economy-stale',
|
|
640
|
+
'medium',
|
|
641
|
+
economy.status !== 200
|
|
642
|
+
? `The published ${economyPath} could not be read (${economy.status || economy.said}).`
|
|
643
|
+
: `The published ${economyPath} is live at ${economy.live?.project ?? 'nothing'}, this folder's at ${localLive?.project ?? 'nothing'}: the page players have talks to another economy.`,
|
|
644
|
+
`${CMD} publish, so the page carries the economy as it is live now.`,
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
return { base: root, probes, key, economy, findings };
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
// ---- The trade-offs, for the person ---------------------------------------------------------------------------------
|
|
651
|
+
|
|
652
|
+
/** Each level in plain words: how the server checks, what a cheater gets, and what the stronger level costs. */
|
|
653
|
+
export const LEVELS = {
|
|
654
|
+
decides: {
|
|
655
|
+
words:
|
|
656
|
+
'the server runs the game itself (a turn, a room, a simulation from a seed it keeps) and the page sends only inputs',
|
|
657
|
+
cheater: 'what a bot that plays well wins, and no more than a player who plays well',
|
|
658
|
+
stronger:
|
|
659
|
+
'there is no stronger level: if a good bot still earns too much, tighten the limits (perPerson with payOnly "x", a perDay total)',
|
|
660
|
+
},
|
|
661
|
+
replays: {
|
|
662
|
+
words: 'the page sends its inputs; the server runs them again and reads the result itself',
|
|
663
|
+
cheater:
|
|
664
|
+
'the best run a script can find: given the whole seed up front it searches offline and sends only that; with randomness revealed as the run goes, what a bot that plays well wins',
|
|
665
|
+
stronger:
|
|
666
|
+
'deciding it: the rules run on the server step by step (a Durable Object a run) and the seed never leaves it; that is the simulation moved to the server, and a request and its CPU for every step of play',
|
|
667
|
+
},
|
|
668
|
+
bounds: {
|
|
669
|
+
words:
|
|
670
|
+
'the page plays and reports the result; the server checks only its own clock, the order of checkpoints and its caps',
|
|
671
|
+
cheater:
|
|
672
|
+
'every claim without playing: a script that waits as long as the game allows and reports the best result is paid, up to the limits',
|
|
673
|
+
stronger:
|
|
674
|
+
'replaying it: a deterministic game step (fixed time step, seeded randomness revealed as the run goes) that the server runs again from the inputs; about 3 ms of CPU a second played, which may need a paid slot (Creator)',
|
|
675
|
+
},
|
|
676
|
+
};
|
|
677
|
+
|
|
678
|
+
/** A grant's limits in words, as they cap a cheater. */
|
|
679
|
+
function ceilingWords(g, payOnly) {
|
|
680
|
+
const l = g.limits ?? {};
|
|
681
|
+
const out = [];
|
|
682
|
+
if (Number(l.perPerson) > 0)
|
|
683
|
+
out.push(`${l.perPerson} a person (an X account${payOnly === 'x' ? '' : ': only with payOnly "x"'})`);
|
|
684
|
+
if (Number(l.perWallet) > 0) out.push(`${l.perWallet} a wallet (a wallet is free)`);
|
|
685
|
+
if (Number(l.perDay) > 0) out.push(`${l.perDay} a day in all`);
|
|
686
|
+
if (Number(l.total) > 0) out.push(`${l.total} in all`);
|
|
687
|
+
return out.length ? out.join(', ') : 'no limit in the grant';
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
/** The paid routes, each with its level, what a cheater gets and what the stronger level costs. */
|
|
691
|
+
export function tradeoffs(spec, { economy = null, facts = {} } = {}) {
|
|
692
|
+
return voucherGrants(spec).map(g => {
|
|
693
|
+
const own = economy?.routes?.find(r => r.key === g.key && (!r.kind || /grant/i.test(r.kind)));
|
|
694
|
+
const trust = TRUST.includes(g.trust) ? g.trust : TRUST.includes(own?.trust) ? own.trust : null;
|
|
695
|
+
const level = LEVELS[trust] ?? null;
|
|
696
|
+
return {
|
|
697
|
+
route: `grant:${g.key}`,
|
|
698
|
+
name: g.name ?? g.key,
|
|
699
|
+
trust,
|
|
700
|
+
declared: g.trust !== undefined,
|
|
701
|
+
how:
|
|
702
|
+
level?.words ??
|
|
703
|
+
'not declared: read PAY.check in server/vouchers.js and say which of decides, replays or bounds it does',
|
|
704
|
+
cheater: `${level ? level.cheater : `it rests on what PAY.check checks: if it only bounds the result, ${LEVELS.bounds.cheater}; if it decides it, ${LEVELS.decides.cheater}`}; capped at ${ceilingWords(g, facts.payOnly)}`,
|
|
705
|
+
// the economy review's own ceiling: what the chain lets one route pay, and what it feeds
|
|
706
|
+
ceiling: own?.ceiling ? String(own.ceiling) : null,
|
|
707
|
+
stronger: level?.stronger ?? null,
|
|
708
|
+
};
|
|
709
|
+
});
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// ---- The record -------------------------------------------------------------------------------------------------------
|
|
713
|
+
|
|
714
|
+
async function nextOut(dir) {
|
|
715
|
+
const root = path.join(dir, 'evidence');
|
|
716
|
+
let n = 0;
|
|
717
|
+
try {
|
|
718
|
+
for (const e of await readdir(root)) {
|
|
719
|
+
const m = /^review-(\d+)$/.exec(e);
|
|
720
|
+
if (m) n = Math.max(n, Number(m[1]));
|
|
721
|
+
}
|
|
722
|
+
} catch {}
|
|
723
|
+
return path.join(root, `review-${n + 1}`);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
export const counts = findings =>
|
|
727
|
+
Object.fromEntries(SEVERITIES.map(s => [s, findings.filter(f => f.severity === s).length]));
|
|
728
|
+
const cell = v =>
|
|
729
|
+
String(v ?? '')
|
|
730
|
+
.replace(/\|/g, '\\|')
|
|
731
|
+
.replace(/\n/g, ' ');
|
|
732
|
+
|
|
733
|
+
/** What the server does about paying, in words: PAY.check and VOUCHERS.payOnly. */
|
|
734
|
+
function serverWords(f) {
|
|
735
|
+
const pay =
|
|
736
|
+
{ template: 'is still the template (it pays nothing yet)', written: 'is written' }[f.pay] ?? 'was not found';
|
|
737
|
+
const who = {
|
|
738
|
+
x: ' (only players signed in with X, a person each)',
|
|
739
|
+
any: ' (any signed-in player: a wallet is free)',
|
|
740
|
+
};
|
|
741
|
+
return `PAY.check ${pay}; VOUCHERS.payOnly ${f.payOnly ? `'${f.payOnly}'${who[f.payOnly] ?? ''}` : 'not found'}`;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** review.md: what the person and their agent read. */
|
|
745
|
+
export function reviewMarkdown(r) {
|
|
746
|
+
const c = r.counts;
|
|
747
|
+
const lines = [
|
|
748
|
+
`# Security review: ${r.game}`,
|
|
749
|
+
'',
|
|
750
|
+
`${r.at} · ${r.xcore} · ${r.economy.version ? `@xgamesfun/solana ${r.economy.version}` : 'no @xgamesfun/solana'}`,
|
|
751
|
+
'',
|
|
752
|
+
`**${c.high ? 'FAIL' : 'PASS'}**: ${SEVERITIES.map(s => `${c[s]} ${s}`).join(', ')}. ${c.high ? 'Fix every high finding before the game pays anything of value.' : 'No high finding.'}`,
|
|
753
|
+
'',
|
|
754
|
+
'## Findings',
|
|
755
|
+
'',
|
|
756
|
+
];
|
|
757
|
+
if (!r.findings.length) lines.push('None.', '');
|
|
758
|
+
for (const s of SEVERITIES) {
|
|
759
|
+
const of = r.findings.filter(f => f.severity === s);
|
|
760
|
+
if (!of.length) continue;
|
|
761
|
+
lines.push(`### ${s[0].toUpperCase()}${s.slice(1)}`, '');
|
|
762
|
+
for (const f of of)
|
|
763
|
+
lines.push(
|
|
764
|
+
`- **${f.id}** (${f.source}${f.route ? `, ${f.route}` : ''}): ${f.message}`,
|
|
765
|
+
...(f.fix ? [` Fix: ${f.fix}`] : []),
|
|
766
|
+
);
|
|
767
|
+
lines.push('');
|
|
768
|
+
}
|
|
769
|
+
lines.push('## Economy', '');
|
|
770
|
+
if (!r.economy.file) lines.push('This game has no economy.', '');
|
|
771
|
+
else if (!r.economy.available) lines.push(`${r.economy.file}: not reviewed: ${r.economy.why}.`, '');
|
|
772
|
+
else {
|
|
773
|
+
lines.push(`${r.economy.file}, reviewed by @xgamesfun/solana ${r.economy.version} (xsolana review).`, '');
|
|
774
|
+
if (r.economy.routes.length) {
|
|
775
|
+
lines.push(
|
|
776
|
+
'| Route | Kind | Trigger | Trusts | Trust | Enforced | Ceiling |',
|
|
777
|
+
'| --- | --- | --- | --- | --- | --- | --- |',
|
|
778
|
+
);
|
|
779
|
+
for (const x of r.economy.routes)
|
|
780
|
+
lines.push(
|
|
781
|
+
`| ${cell(x.name ?? x.key)} | ${cell(x.kind)} | ${cell(x.trigger)} | ${cell(Array.isArray(x.trusts) ? x.trusts.join(', ') : x.trusts)} | ${cell(x.trust)} | ${cell(Array.isArray(x.enforced) ? x.enforced.join(', ') : x.enforced)} | ${cell(x.ceiling)} |`,
|
|
782
|
+
);
|
|
783
|
+
lines.push('');
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
const f = r.server.facts;
|
|
787
|
+
lines.push(
|
|
788
|
+
'## Server',
|
|
789
|
+
'',
|
|
790
|
+
`- server/: ${f.server ? 'yes' : 'none'}; server/vouchers.js: ${f.vouchers ? 'yes' : 'none'}`,
|
|
791
|
+
...(f.vouchers
|
|
792
|
+
? [
|
|
793
|
+
`- PAY.check: ${{ template: 'still the template (pays nothing)', written: 'written', unreadable: 'not found in the file' }[f.pay]}`,
|
|
794
|
+
`- VOUCHERS.payOnly: ${f.payOnly ? `'${f.payOnly}'` : 'not found'}`,
|
|
795
|
+
]
|
|
796
|
+
: []),
|
|
797
|
+
`- api/ routes the code names: ${r.server.routes.length ? r.server.routes.map(x => `\`${x.path}\``).join(', ') : 'none'}`,
|
|
798
|
+
'',
|
|
799
|
+
'## Live',
|
|
800
|
+
'',
|
|
801
|
+
);
|
|
802
|
+
if (!r.live) lines.push(`Not run: ${r.liveWhy ?? `${CMD} review --live`}.`, '');
|
|
803
|
+
else {
|
|
804
|
+
lines.push(`Asked ${r.live.base} as a stranger (no sign-in, and a forged one).`, '');
|
|
805
|
+
if (r.live.probes.length) {
|
|
806
|
+
lines.push('| Route | Request | Sign-in | Answer |', '| --- | --- | --- | --- |');
|
|
807
|
+
for (const p of r.live.probes)
|
|
808
|
+
lines.push(
|
|
809
|
+
`| \`${p.route}\` | ${p.method} | ${p.auth} | ${p.status || 'none'}${p.ok === null ? '' : ` ok:${p.ok}`}${p.said ? ` ${cell(p.said)}` : ''} |`,
|
|
810
|
+
);
|
|
811
|
+
lines.push('');
|
|
812
|
+
}
|
|
813
|
+
if (r.live.key) lines.push(`- api/vouchers/key: ${r.live.key.publicKey ?? `none (${r.live.key.status})`}`);
|
|
814
|
+
if (r.live.economy)
|
|
815
|
+
lines.push(
|
|
816
|
+
`- published ${r.live.economy.path}: ${r.live.economy.same ? 'live at the same economy as this folder' : 'not the economy this folder has live'}`,
|
|
817
|
+
);
|
|
818
|
+
lines.push('');
|
|
819
|
+
}
|
|
820
|
+
lines.push(
|
|
821
|
+
'## For the person: what a cheater gets',
|
|
822
|
+
'',
|
|
823
|
+
'Show the person this section in plain words, and let them choose the trade-off.',
|
|
824
|
+
'',
|
|
825
|
+
);
|
|
826
|
+
if (r.economy.tradeoffs) lines.push(r.economy.tradeoffs.trim(), '');
|
|
827
|
+
if (!r.tradeoffs.length)
|
|
828
|
+
lines.push('No route pays a result through a voucher: nothing here rests on what the server checks.', '');
|
|
829
|
+
for (const t of r.tradeoffs)
|
|
830
|
+
lines.push(
|
|
831
|
+
`### ${t.name} (\`${t.route.replace(/^grant:/, '')}\`)`,
|
|
832
|
+
'',
|
|
833
|
+
`- **How the server checks it**: ${t.trust ? `${t.trust}${t.declared ? '' : ' (from the economy review; not declared in the grant)'}: ` : ''}${t.how}.`,
|
|
834
|
+
`- **What a cheater gets**: ${t.cheater}.`,
|
|
835
|
+
...(t.ceiling ? [`- **The economy review's ceiling**: ${t.ceiling.replace(/\.$/, '')}.`] : []),
|
|
836
|
+
...(r.server.facts.vouchers ? [`- **Its server**: ${serverWords(r.server.facts)}.`] : []),
|
|
837
|
+
...(t.stronger ? [`- **The stronger level**: ${t.stronger}.`] : []),
|
|
838
|
+
'',
|
|
839
|
+
);
|
|
840
|
+
lines.push(
|
|
841
|
+
`The guide: ${GUIDE}, and @xgamesfun/solana's skill reference, skills/xsolana-economy/references/trust-and-cheating.md.`,
|
|
842
|
+
'',
|
|
843
|
+
);
|
|
844
|
+
return lines.join('\n');
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* The whole review of the game in `dir`. live: the base to ask (or null); out: where the record goes (default
|
|
849
|
+
* evidence/review-N/). → the record, with outDir
|
|
850
|
+
*/
|
|
851
|
+
export async function reviewGame(
|
|
852
|
+
dir,
|
|
853
|
+
{ live = null, liveWhy = null, out = null, env = process.env, fetchImpl = fetch, now = new Date() } = {},
|
|
854
|
+
) {
|
|
855
|
+
const home = economyHome(dir);
|
|
856
|
+
const file = economyFile(home);
|
|
857
|
+
const spec = file ? readJson(file) : null;
|
|
858
|
+
const economy = file
|
|
859
|
+
? {
|
|
860
|
+
file: posix(path.relative(dir, file)),
|
|
861
|
+
...(spec ? economyReview(file, { env }) : { available: false, version: null, why: 'it is not valid JSON' }),
|
|
862
|
+
}
|
|
863
|
+
: { file: null, available: false, version: solanaPackage(env)?.version ?? null, why: 'no economy' };
|
|
864
|
+
const addressOf = async seed => (await import('./add.mjs')).seedAddress(seed);
|
|
865
|
+
const server = await serverReview(dir, { spec, economy: file, available: economy.available, addressOf });
|
|
866
|
+
// the economy review says what it says about trust; this package's own line on it only when it says nothing
|
|
867
|
+
const theirs = economy.available ? economy.findings : [];
|
|
868
|
+
const own = server.findings.filter(
|
|
869
|
+
f =>
|
|
870
|
+
f.id !== 'trust-undeclared' || !theirs.some(t => /trust/i.test(t.id ?? '') && (!t.route || t.route === f.route)),
|
|
871
|
+
);
|
|
872
|
+
let liveReport = null;
|
|
873
|
+
if (live)
|
|
874
|
+
liveReport = await liveReview(live, {
|
|
875
|
+
routes: server.routes,
|
|
876
|
+
keys: [...new Set(voucherGrants(spec).flatMap(g => g.vouchers))],
|
|
877
|
+
testKey: server.facts.testKey ?? null,
|
|
878
|
+
economyPath: file ? posix(path.relative(home, file)) : null,
|
|
879
|
+
localLive: spec?.live ?? null,
|
|
880
|
+
vouchers: server.facts.vouchers,
|
|
881
|
+
fetchImpl,
|
|
882
|
+
});
|
|
883
|
+
const findings = [...theirs.map(f => ({ source: 'economy', ...f })), ...own, ...(liveReport?.findings ?? [])].sort(
|
|
884
|
+
(a, b) => SEVERITIES.indexOf(a.severity) - SEVERITIES.indexOf(b.severity),
|
|
885
|
+
);
|
|
886
|
+
const r = {
|
|
887
|
+
ok: !findings.some(f => f.severity === 'high'),
|
|
888
|
+
game: path.basename(path.resolve(dir)),
|
|
889
|
+
at: now.toISOString(),
|
|
890
|
+
xcore: `${PACKAGE.name} ${PACKAGE.version}`,
|
|
891
|
+
counts: counts(findings),
|
|
892
|
+
findings,
|
|
893
|
+
economy,
|
|
894
|
+
server: { facts: server.facts, routes: server.routes },
|
|
895
|
+
live: liveReport,
|
|
896
|
+
...(liveReport ? {} : { liveWhy }),
|
|
897
|
+
tradeoffs: tradeoffs(spec, { economy, facts: server.facts }),
|
|
898
|
+
};
|
|
899
|
+
const outDir = out ? path.resolve(out) : await nextOut(dir);
|
|
900
|
+
await mkdir(outDir, { recursive: true });
|
|
901
|
+
await writeFile(path.join(outDir, 'review.json'), JSON.stringify(r, null, 2) + '\n');
|
|
902
|
+
await writeFile(path.join(outDir, 'review.md'), reviewMarkdown(r));
|
|
903
|
+
return { ...r, outDir };
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
// ---- The nudge publish gives ------------------------------------------------------------------------------------------
|
|
907
|
+
|
|
908
|
+
/** The newest change to a file or under a folder (hidden files and node_modules left out), in ms; 0 when none. */
|
|
909
|
+
function newest(p) {
|
|
910
|
+
let t = 0;
|
|
911
|
+
const walk = q => {
|
|
912
|
+
let s;
|
|
913
|
+
try {
|
|
914
|
+
s = statSync(q);
|
|
915
|
+
} catch {
|
|
916
|
+
return;
|
|
917
|
+
}
|
|
918
|
+
if (s.isDirectory()) {
|
|
919
|
+
for (const e of readdirSyncSafe(q)) if (!e.startsWith('.') && e !== 'node_modules') walk(path.join(q, e));
|
|
920
|
+
} else t = Math.max(t, s.mtimeMs);
|
|
921
|
+
};
|
|
922
|
+
walk(p);
|
|
923
|
+
return t;
|
|
924
|
+
}
|
|
925
|
+
function readdirSyncSafe(q) {
|
|
926
|
+
try {
|
|
927
|
+
return readdirSync(q);
|
|
928
|
+
} catch {
|
|
929
|
+
return [];
|
|
930
|
+
}
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/**
|
|
934
|
+
* One line for publish when the game pays results through voucher grants and no review (evidence/review-N/) is newer
|
|
935
|
+
* than its economy file and server/; else null. A nudge only: publish goes on.
|
|
936
|
+
*/
|
|
937
|
+
export function reviewNudge(dir) {
|
|
938
|
+
let file;
|
|
939
|
+
try {
|
|
940
|
+
file = economyFile(economyHome(dir));
|
|
941
|
+
} catch {
|
|
942
|
+
return null;
|
|
943
|
+
}
|
|
944
|
+
if (!file || !voucherGrants(readJson(file)).length) return null;
|
|
945
|
+
const changed = Math.max(newest(file), newest(path.join(dir, 'server')));
|
|
946
|
+
let reviewed = 0;
|
|
947
|
+
for (const e of readdirSyncSafe(path.join(dir, 'evidence')))
|
|
948
|
+
if (/^review-\d+$/.test(e)) reviewed = Math.max(reviewed, newest(path.join(dir, 'evidence', e, 'review.json')));
|
|
949
|
+
if (reviewed > changed) return null;
|
|
950
|
+
return `This game pays results through voucher grants, and ${CMD} review has not run since its economy or server/ changed: run it before players are paid (it says what a cheater gets, and what to fix).`;
|
|
951
|
+
}
|