@xgamesfun/xcore 0.3.0 → 0.4.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/lib/review.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  // the server this package's own static reading of server/ and the page: PAY.check written or still the template,
7
7
  // one that pays from the request alone, VOUCHERS.payOnly against the grants, the voucher key and the
8
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).
9
+ // seed in the page's files, a server of the game's own that does work before (or without) checking a
10
+ // player's token, and every api/ route the code names (for the live part).
10
11
  // live (--live) each route asked with no sign-in and with a forged one, GET and POST; api/vouchers/key
11
12
  // against the grants' keys; the published economy file's `live` against this folder's. Read-only:
12
13
  // nothing it sends could spend or sign (a route that pays gets an empty body).
@@ -271,6 +272,217 @@ function gitState(dir, rel) {
271
272
  }
272
273
 
273
274
  const TEXT_CODE = /\.(m?js|cjs|jsx|tsx?|html?|svelte|vue|astro)$/i;
275
+
276
+ // ---- Work done before a token is checked ----------------------------------------------------------------------------
277
+ // A request with no good token is a stranger's, and the work a server does for strangers counts toward what caps the
278
+ // game (a capped game's server is switched off until the month ends). So: check the token before any work. Read simply,
279
+ // to say nothing that is not so, over the game's own server files (not the parts xcore writes, which check where they
280
+ // must or are public by design), with comments and strings left out:
281
+ // work a call to the platform's saves, scores, files or loot (env.GF.saves.get, env.GF?.saves?.get, through
282
+ // `const g = env.GF` or `const { GF } = env` too), a database, a Durable Object, or a fetch() out;
283
+ // a check env.GF.verifyPlayer, gf-db.js's withPlayer or a JWT library's jwtVerify, or a call to a function of the
284
+ // game's own that makes one (in any of its own files, through other functions of its own);
285
+ // not decided: a binding from a package whose name says it checks (a router's jwt or auth middleware): nothing is said.
286
+ // Named when the game's own files do work and make no check anywhere (medium), and when the entry's fetch does work
287
+ // before its first check (low: a public read there is fine, and the person decides), unless the only work is waking a
288
+ // Durable Object whose class checks the token itself.
289
+ /** The files xcore writes into server/ (lib/add.mjs), each checking a token where it must or public by design. */
290
+ const XCORE_PARTS = new Set(['database.js', 'gf-docs.js', 'gf-db.js', 'rooms.js', 'rpc.js', 'vouchers.js']);
291
+ const TOKEN_CHECK = /(?<![\w$])(?:verifyPlayer|withPlayer|jwtVerify)\s*(?:\?\.\s*)?\(/;
292
+ const DOT = '\\s*\\??\\.\\s*';
293
+ const CALL = '\\s*(?:\\?\\.\\s*)?\\(';
294
+ /** The kinds of work, as patterns over code whose platform binding may also be called `gf` (its aliases). */
295
+ const workPatterns = gf => [
296
+ ['saves', new RegExp(`(?<![\\w$])(?:${gf})${DOT}saves${DOT}[\\w$]+${CALL}`)],
297
+ ['scores', new RegExp(`(?<![\\w$])(?:${gf})${DOT}scores${DOT}[\\w$]+${CALL}`)],
298
+ ['files', new RegExp(`(?<![\\w$])(?:${gf})${DOT}files${DOT}[\\w$]+${CALL}`)],
299
+ ['loot', new RegExp(`(?<![\\w$])(?:${gf})${DOT}loot${DOT}[\\w$]+${CALL}`)],
300
+ [
301
+ 'a database',
302
+ new RegExp(
303
+ `\\benv${DOT}[A-Z][A-Z0-9_]*${DOT}(?:prepare|batch|exec|dump)${CALL}|\\b(?:db|docsOf|dataOf)\\s*\\(\\s*env\\s*\\)`,
304
+ ),
305
+ ],
306
+ [
307
+ 'a Durable Object',
308
+ new RegExp(`\\benv${DOT}[A-Z][A-Z0-9_]*${DOT}(?:get|idFromName|idFromString|newUniqueId|getByName)${CALL}`),
309
+ ],
310
+ ['a fetch out', /(?<![\w$.])fetch\s*\(/],
311
+ ];
312
+ /** Code with every string and template literal blanked out (lengths and lines kept): a word there is never a call. */
313
+ export function withoutStrings(code) {
314
+ let out = '';
315
+ for (let i = 0; i < code.length;) {
316
+ const c = code[i];
317
+ if (c !== '"' && c !== "'" && c !== '`') {
318
+ out += c;
319
+ i++;
320
+ continue;
321
+ }
322
+ let j = i + 1;
323
+ while (j < code.length && code[j] !== c && !(c !== '`' && code[j] === '\n')) j += code[j] === '\\' ? 2 : 1;
324
+ out += c + code.slice(i + 1, j).replace(/[^\n]/g, ' ') + (j < code.length ? c : '');
325
+ i = j + 1;
326
+ }
327
+ return out;
328
+ }
329
+ /** A method's own `fetch(…) {` taken out, so only calls remain. */
330
+ const withoutFetchHandlers = code =>
331
+ code.replace(/\b(?:async\s+)?fetch\s*(?::\s*(?:async\s*)?(?:function\s*)?)?\(([^()]*)\)\s*(?:=>\s*)?\{/g, m =>
332
+ ' '.repeat(m.length),
333
+ );
334
+ /** The names `env.GF` goes by in `code`: GF, and `const g = env.GF` aliases. → a pattern's alternatives */
335
+ const gfNames = code =>
336
+ [
337
+ 'GF',
338
+ ...[
339
+ ...code.matchAll(
340
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:this\s*\.\s*)?env\s*\??\.\s*GF\b(?!\s*\??\.)/g,
341
+ ),
342
+ ].map(m => m[1]),
343
+ ]
344
+ .map(n => n.replace(/\$/g, '\\$'))
345
+ .join('|');
346
+ /** The kinds of work `code` does, and where the first one is. → {kinds: [word], at} */
347
+ function workIn(code, gf = 'GF') {
348
+ const c = withoutFetchHandlers(code);
349
+ const hits = workPatterns(gf)
350
+ .map(([what, re]) => [what, re.exec(c)?.index ?? -1])
351
+ .filter(([, at]) => at >= 0);
352
+ return { kinds: hits.map(([what]) => what), at: hits.length ? Math.min(...hits.map(([, at]) => at)) : -1 };
353
+ }
354
+ /** The functions (and methods) `code` defines: [{name, body}]. A body is its braces, or an arrow's line. */
355
+ function definitions(code) {
356
+ const out = [];
357
+ const add = (name, from) => {
358
+ const brace = code.indexOf('{', from);
359
+ const eol = code.indexOf('\n', from);
360
+ const body =
361
+ brace >= 0 && (eol < 0 || brace < eol)
362
+ ? (braced(code, brace) ?? '')
363
+ : code.slice(from, eol < 0 ? undefined : eol);
364
+ out.push({ name, body });
365
+ };
366
+ for (const m of code.matchAll(/\bfunction\s*\*?\s*([A-Za-z_$][\w$]*)\s*\(/g)) add(m[1], m.index + m[0].length);
367
+ for (const m of code.matchAll(
368
+ /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:async\b|function\b|\(|[A-Za-z_$][\w$]*\s*=>)/g,
369
+ ))
370
+ add(m[1], m.index + m[0].length);
371
+ // methods of a class or an object literal (wherever they start: { async check(…) { … } }), and an object's
372
+ // arrow or function properties (check: async (…) => { … })
373
+ for (const m of code.matchAll(/(?:^|[{},;\s])(?:static\s+)?(?:async\s+)?\*?\s*([A-Za-z_$][\w$]*)\s*\([^()]*\)\s*\{/g))
374
+ if (!/^(?:if|for|while|switch|catch|function|return|fetch|with)$/.test(m[1])) add(m[1], m.index + m[0].length - 1);
375
+ for (const m of code.matchAll(
376
+ /(?:^|[{,\s])([A-Za-z_$][\w$]*)\s*:\s*(?:async\s*)?(?:function\b[^(]*\(|\(|[A-Za-z_$][\w$]*\s*=>)/g,
377
+ ))
378
+ if (m[1] !== 'fetch') add(m[1], m.index + m[0].length);
379
+ return out;
380
+ }
381
+ /** The names of the game's own functions that check a token, directly or through another of them. */
382
+ function checkingFunctions(codes) {
383
+ const defs = codes.flatMap(definitions);
384
+ const names = new Set(defs.filter(d => TOKEN_CHECK.test(d.body)).map(d => d.name));
385
+ for (let changed = true; changed;) {
386
+ changed = false;
387
+ for (const d of defs)
388
+ if (!names.has(d.name) && [...names].some(n => callOf(n).test(d.body))) {
389
+ names.add(d.name);
390
+ changed = true;
391
+ }
392
+ }
393
+ return names;
394
+ }
395
+ const callOf = name => new RegExp(`(?<![\\w$])${name.replace(/\$/g, '\\$')}\\s*(?:\\?\\.\\s*)?\\(`);
396
+ /** Where the first check is in `code`: a token check, or a call to a checking function. → index or -1 */
397
+ function firstCheck(code, checking) {
398
+ const at = [TOKEN_CHECK, ...[...checking].map(callOf)].map(re => re.exec(code)?.index ?? -1).filter(i => i >= 0);
399
+ return at.length ? Math.min(...at) : -1;
400
+ }
401
+ /** The default export's fetch body in comment-free code, or null. */
402
+ function fetchBody(code) {
403
+ const start = /export\s+default\s*\{/.exec(code);
404
+ if (!start) return null;
405
+ const obj = braced(code, start.index + start[0].length - 1);
406
+ if (!obj) return null;
407
+ const m = /\b(?:async\s+)?fetch\s*(?::\s*(?:async\s*)?(?:function\s*)?)?\(([^()]*)\)\s*(?:=>\s*)?\{/.exec(obj);
408
+ return m ? braced(obj, m.index + m[0].length - 1) : null;
409
+ }
410
+ /**
411
+ * An import from a package that says it checks a sign-in (a router's jwt or auth middleware): a whole word of the
412
+ * package's name or path (auth, @scope/auth, x-auth, auth-kit, hono/jwt) or of a name it imports (jwt, requireAuth,
413
+ * verifyToken), never a word that only has one inside it (authority, authorName).
414
+ */
415
+ const PACKAGE_IMPORT =
416
+ /\bimport\s+(?:([A-Za-z_$][\w$]*)\s*,?\s*)?(?:\{([^}]*)\}|\*\s*as\s+[A-Za-z_$][\w$]*)?\s*from\s*['"]((?![./])[^'"]+)['"]/g;
417
+ const SIGN_IN_WORDS = new Set([
418
+ 'auth',
419
+ 'oauth',
420
+ 'jwt',
421
+ 'jose',
422
+ 'bearer',
423
+ 'session',
424
+ 'verify',
425
+ 'authenticate',
426
+ 'authorize',
427
+ ]);
428
+ /** A name's words: split at - _ . / @ and at CamelCase, lower case. */
429
+ const wordsOfName = s =>
430
+ String(s)
431
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
432
+ .toLowerCase()
433
+ .split(/[^a-z0-9]+/)
434
+ .filter(Boolean);
435
+ const packageChecks = code =>
436
+ [...code.matchAll(PACKAGE_IMPORT)].some(m =>
437
+ [m[3], m[1] ?? '', ...(m[2] ?? '').split(',').map(x => x.trim().split(/\s+as\s+/)[0])].some(n =>
438
+ wordsOfName(n).some(w => SIGN_IN_WORDS.has(w)),
439
+ ),
440
+ );
441
+ const TOKEN_WHY =
442
+ "The work a server does for a caller with no token counts toward what caps the game, and a capped game's server is switched off until the month ends.";
443
+ const TOKEN_FIX =
444
+ 'Check the game token before any work: answer 401 at once when the request has none, then const player = await env.GF.verifyPlayer(token) (it checks the token\'s signature, its audience and its expiry) and 401 when it is not ok; keep only what is truly public (a leaderboard read) before it (docs/SERVER.md, "Check the token first").';
445
+
446
+ /** The findings for work done without, or before, a token check (see above). `files`: [{f, code (comments out), text}]. */
447
+ function tokenFindings(dir, files, add) {
448
+ const own = files
449
+ .filter(({ f, text }) => !XCORE_PARTS.has(f) && !(f === 'index.js' && /Written by `xcore add/.test(text)))
450
+ .map(x => ({ ...x, bare: withoutStrings(x.code) }));
451
+ if (!own.length || own.some(x => packageChecks(x.code))) return; // not decided: nothing said
452
+ const working = own.map(x => ({ ...x, work: workIn(x.bare, gfNames(x.bare)) })).filter(x => x.work.kinds.length);
453
+ if (!working.length) return;
454
+ const checking = checkingFunctions(own.map(x => x.bare));
455
+ if (!own.some(x => TOKEN_CHECK.test(x.bare))) {
456
+ const names = working.map(x => `server/${x.f}`);
457
+ const kinds = [...new Set(working.flatMap(x => x.work.kinds))];
458
+ add(
459
+ 'server-no-token-check',
460
+ 'medium',
461
+ `${names.join(', ')} ${names.length === 1 ? 'does' : 'do'} work for every request (${kinds.join(', ')}) and never check${names.length === 1 ? 's' : ''} a player's token: anyone, signed in or not, can make it spend the game's allowance. ${TOKEN_WHY}`,
462
+ TOKEN_FIX,
463
+ );
464
+ return;
465
+ }
466
+ // the entry's fetch: work before its first check
467
+ const cfg = readJson(path.join(dir, 'gamesfun.json'));
468
+ const main = typeof cfg?.server?.main === 'string' ? cfg.server.main.replace(/^\.\//, '') : 'server/index.js';
469
+ const entry = own.find(x => `server/${x.f}` === main);
470
+ const body = entry ? fetchBody(entry.bare) : null;
471
+ if (!body) return;
472
+ const work = workIn(body, gfNames(entry.bare));
473
+ if (work.at < 0) return;
474
+ const check = firstCheck(body, checking);
475
+ if (check >= 0 && check < work.at) return;
476
+ // only a room woken, and the room's class checks the token itself: the room turns the stranger away
477
+ const rooms = own.filter(x => /\bextends\s+DurableObject\b/.test(x.bare));
478
+ if (work.kinds.every(k => k === 'a Durable Object') && rooms.some(x => firstCheck(x.bare, checking) >= 0)) return;
479
+ add(
480
+ 'work-before-token',
481
+ 'low',
482
+ `${main}'s fetch does work (${work.kinds.join(', ')}) before it checks a player's token${check < 0 ? ' (it checks none there itself)' : ''}: fine for what is truly public (a leaderboard read), but every stranger's request runs it. ${TOKEN_WHY}`,
483
+ TOKEN_FIX,
484
+ );
485
+ }
274
486
  const OWN_COPIES = ['gf', 'gamesfun.js']; // xcore's and loot's copies in the page: not the game's own routes
275
487
 
276
488
  /**
@@ -291,11 +503,14 @@ export async function serverReview(dir, { spec = null, economy = null, available
291
503
  TEXT_CODE.test(f),
292
504
  );
293
505
  let serverCode = '';
506
+ const read = [];
294
507
  for (const f of serverFiles) {
295
508
  const text = await readFile(path.join(dir, 'server', f), 'utf8');
296
509
  serverCode += `\n${withoutComments(text)}`;
510
+ read.push({ f, code: withoutComments(text), text });
297
511
  for (const r of apiRoutes(text)) named(r, `server/${f}`);
298
512
  }
513
+ tokenFindings(dir, read, add);
299
514
  // A token checked by hand (a JWT library against <play host>/player/v1/key): every game's tokens are signed with the
300
515
  // 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
516
  // whose creator then replays the token here). env.GF.verifyPlayer checks it; a hand-made check must name it.
@@ -1,58 +1,67 @@
1
1
  // Key-shaped strings: the one list of them. The publish scan (./publish/scan.mjs) reads every pattern here, and the
2
2
  // kit imports SECRET_PATTERNS for its own scanner. Pure: no imports.
3
3
  //
4
- // Every run in a pattern has an upper bound, so no match is longer than MAX_MATCH characters: the scan reads a big
5
- // file in pieces that overlap by more than that, and finds a key that straddles two pieces (tests/scan.test.mjs checks
6
- // each pattern's longest match against it). The bounds are far past any real key's length (a JWT with a large payload,
7
- // a long secret pasted into code), so bounding a pattern never lets one through that an unbounded one caught.
4
+ // Every pattern runs in time linear in the file, as xgames.fun's own scan does: every run has an upper bound, nothing
5
+ // optional repeats inside a repeat, a look-behind reads a few characters at most, and a pattern that starts inside a run
6
+ // of the characters it then reads (a JWT, an RPC URL's host) starts only where that run begins, so a long run is read once
7
+ // rather than once for each place it could start (tests/scan.test.mjs times each on 4 MiB of text written to hold it
8
+ // up). No match is longer than MAX_MATCH characters, so the scan reads a big file in pieces that overlap by more than
9
+ // that, and finds a key that straddles two pieces (tests/scan.test.mjs checks each pattern's longest match against it).
10
+ // Each pattern xgames.fun's own scan has is here as it is there, with the same bounds, so the kit refuses what the
11
+ // host refuses, and nothing the host would publish (tests/scan.test.mjs holds the *_SECRET literal's bounds to it).
12
+ // Three are the kit's own, for the key formats it writes itself (a signing key's seed, a {pub, seed} key file, secret
13
+ // key bytes in code): the host does not read those, and a publish that carries one is refused here first.
8
14
 
9
- /** The longest text any pattern here can match (the JWT's: 4,096 + 16,384 + 2,048 and its marks). */
10
- export const MAX_MATCH = 32 * 1024;
15
+ /** The longest text any pattern here can match (the JWT's: 8,192 + 16,384 + 8,192 and its marks). */
16
+ export const MAX_MATCH = 40 * 1024;
11
17
 
12
18
  /**
13
19
  * [kind, pattern]. Each kind is the words a refusal names it by. The last, 'JSON web token', is any JWT: the publish
14
20
  * scan reads a JWT's role instead (a Supabase anon key is public by design), and the kit replaces it the same way.
15
21
  */
16
22
  export const SECRET_PATTERNS = [
17
- ['private key block', /-----BEGIN (?:[A-Z]{1,20} )?PRIVATE KEY-----/],
23
+ ['private key block', /-----BEGIN (?:[A-Z]{1,32} )?PRIVATE KEY-----/],
18
24
  // not inside a word or a class name (style-sk-aria-pressed): a key stands on its own
19
25
  [
20
26
  'OpenAI or Anthropic key',
21
- /(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,500}|[A-Za-z0-9]{32,500})/,
27
+ /(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,256}|[A-Za-z0-9]{32,256})/,
22
28
  ],
23
- ['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,500}/],
29
+ ['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,256}/],
24
30
  ['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
25
- ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,500}|github_pat_[A-Za-z0-9_]{30,500})/],
26
- ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,500}/],
31
+ ['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,256}|github_pat_[A-Za-z0-9_]{30,256})/],
32
+ ['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,256}/],
27
33
  ['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
28
- ['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,500}/],
29
- ['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,500}/],
30
- ['Supabase access token', /\bsbp_[A-Za-z0-9]{20,500}/],
31
- ['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,200}:[^\s@/'"]{1,200}@/i],
32
- ['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,500}/],
34
+ ['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,256}/],
35
+ ['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,256}/],
36
+ ['Supabase access token', /\bsbp_[A-Za-z0-9]{20,256}/],
37
+ ['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,256}:[^\s@/'"]{1,256}@/i],
38
+ ['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,256}/],
33
39
  // providers whose keys have a shape of their own (each prefix, length and alphabet as the provider issues them)
34
- ['Hugging Face token', /\bhf_[A-Za-z0-9]{34,500}\b/],
40
+ ['Hugging Face token', /\bhf_[A-Za-z0-9]{34,256}\b/],
35
41
  ['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
36
- ['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,500}/],
42
+ ['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,256}/],
37
43
  ['SendGrid key', /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/],
38
- ['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,500}/],
44
+ ['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,256}/],
39
45
  ['Shopify token', /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/],
40
46
  ['Google OAuth client secret', /\bGOCSPX-[A-Za-z0-9_-]{28}\b/],
41
47
  ['DigitalOcean token', /\bdo[opr]_v1_[a-f0-9]{64}\b/],
42
48
  ['Telegram bot token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/],
43
- // a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key
49
+ // a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key. The host is
50
+ // read only from where it starts (right after the scheme) and up to a DNS name's 253 characters; helius's key is in
51
+ // the query, at most 512 characters along
44
52
  [
45
53
  'an RPC URL with its key (helius, alchemy, quicknode, infura)',
46
- /https?:\/\/[^\s"'`]{0,1000}(?:helius-rpc\.com[^\s"'`]{0,300}[?&]api-key=[A-Za-z0-9-]{16,200}|\.alchemy\.com\/v2\/[A-Za-z0-9_-]{20,200}|\.quiknode\.pro\/[A-Za-z0-9]{20,200}|\.infura\.io\/v3\/[a-f0-9]{32})/,
54
+ /https?:\/\/[A-Za-z0-9.-]{0,253}(?:helius-rpc\.com[^\s"'`]{0,512}?[?&]api-key=[A-Za-z0-9-]{16,256}|\.alchemy\.com\/v2\/[A-Za-z0-9_-]{20,256}|\.quiknode\.pro\/[A-Za-z0-9]{20,256}|\.infura\.io\/v3\/[a-f0-9]{32})/,
47
55
  ],
48
56
  [
49
57
  'a Solana keypair in code (fromSecretKey)',
50
- /\bfromSecretKey\s{0,20}\(\s{0,20}(?:(?:new\s{1,20}Uint8Array|Uint8Array\.from|bs58\.decode)\s{0,20}\(\s{0,20})?[["'`]/,
58
+ /\bfromSecretKey\s{0,16}\(\s{0,16}(?:(?:new\s{1,16}Uint8Array|Uint8Array\.from|bs58\.decode)\s{0,16}\(\s{0,16})?[["'`]/,
51
59
  ],
52
60
  [
53
61
  'secret assigned a literal',
54
- // not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText'); the value is its first group
55
- /(?<!\?\s*["']?)\b[A-Za-z0-9_]{0,100}(?:SECRET|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_TOKEN|SERVICE_ROLE|PASSWORD)[A-Za-z0-9_]{0,100}["']?\s{0,20}[:=]\s{0,20}["']([A-Za-z0-9+/_=.-]{16,16384})["']/i,
62
+ // not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText'), the spaces looked back over up to 16
63
+ // (unbounded, the look-behind read every space before every place in a file of spaces); the value is its first group
64
+ /(?<!\?\s{0,16}["']?)\b[A-Za-z0-9_]{0,64}(?:SECRET|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_TOKEN|SERVICE_ROLE|PASSWORD)[A-Za-z0-9_]{0,64}["']?\s{0,16}[:=]\s{0,16}["']([A-Za-z0-9+/_=.-]{16,1024})["']/i,
56
65
  ],
57
66
  // a signing key's 32-byte seed in hex, by its name (VOUCHER_SEED=…, voucherSeed: '…'): xcore add vouchers' format
58
67
  [
@@ -71,6 +80,7 @@ export const SECRET_PATTERNS = [
71
80
  ],
72
81
  // never touching + / = : those break up base64 data (an inlined picture), where a run of base58 letters is chance
73
82
  ['Solana secret key (base58)', /(?<![A-Za-z0-9+/])[1-9A-HJ-NP-Za-km-z]{86,90}(?![A-Za-z0-9+/=])/],
74
- // header, payload (16K: a token with many claims), signature (an RS512 one with a 8192-bit key is 1,366)
75
- ['JSON web token', /\beyJ[A-Za-z0-9_-]{10,4096}\.eyJ[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{16,2048}/],
83
+ // header, payload (16K: a token with many claims), signature (an RS512 one with a 8192-bit key is 1,366); it starts
84
+ // where its run of base64url letters starts, never at an "eyJ" inside one
85
+ ['JSON web token', /(?<![A-Za-z0-9_-])eyJ[A-Za-z0-9_-]{10,8192}\.eyJ[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{16,8192}/],
76
86
  ];
@@ -2,10 +2,15 @@
2
2
  // into one ES module, and the same checks run for the kit's dev server, `xcore publish --check` and the publish itself:
3
3
  // - it builds (an import that does not resolve, a syntax error: esbuild's own words, with the file and line);
4
4
  // - every file it reads is inside the game folder (a game's own node_modules counts), never the kit or the machine;
5
- // - Node built-ins only with nodejs_compat on (gamesfun.json), as `node:<name>`, left for the runtime; those it does
6
- // not give a Worker (node:fs, child_process, vm, …: contract.mjs NODE_REFUSED) refuse it;
7
- // - no code made from text at run time: eval, new Function, a dynamic import() of anything but a literal path,
8
- // importScripts (a Worker refuses them anyway; here the person reads where);
5
+ // - no Node built-ins (xgames.fun offers no nodejs_compat: contract.mjs ALLOWED_FLAGS), named in words;
6
+ // - what xgames.fun refuses in a bundle: an import, a re-export or an import() of a module that opens raw sockets or
7
+ // loads modules by name (contract.mjs SOCKET_MODULES), and an import() of anything but a plain string, which could
8
+ // name any module at run time. The verdict is the bundle's, as xgames.fun reads the bundle it is sent: the bundle
9
+ // the kit would send is read again by esbuild's own parser (bundleImports; never by searching its text), so code
10
+ // esbuild leaves out (a package's export the game never uses) refuses nothing. Each refusal names the file that
11
+ // has the import, as esbuild saw it while bundling;
12
+ // - no code made from text at run time: eval, new Function, importScripts (a Worker refuses them anyway; here the
13
+ // person reads where);
9
14
  // - no secrets: the publish scan (../publish/scan.mjs) over each server file and over the bundle;
10
15
  // - a default export (the Worker), and each room class gamesfun.json declares exported by that name;
11
16
  // - the size: xgames.fun's cap (5 MB, the wasm included), with a word above 1 MB;
@@ -25,11 +30,11 @@ import { codeOnly, dataCallLine, ownerlessDataCalls } from './data-calls.mjs';
25
30
  import {
26
31
  CONFIG_FILE,
27
32
  LIMITS,
28
- NODE_REFUSED,
29
33
  OLD_DEFAULT_RULES,
30
34
  OWNER_SPACES,
31
35
  readServerConfig,
32
36
  BUNDLE_NAME,
37
+ socketModule,
33
38
  usesFiles,
34
39
  } from './contract.mjs';
35
40
 
@@ -79,15 +84,86 @@ const NODE_BUILTINS = new Set([
79
84
  'worker_threads',
80
85
  'zlib',
81
86
  ]);
82
- /** Code made from text at run time: [what, pattern]. Matched in each file the bundle reads. */
87
+ /**
88
+ * Code made from text at run time: [what, pattern]. Matched in each file the bundle reads. (An import() of a name made
89
+ * at run time is read by the parse instead: refusedImports below.)
90
+ */
83
91
  export const FORBIDDEN = [
84
92
  ['eval()', /(?<![.\w$])eval\s*\(/],
85
93
  ['new Function()', /\bnew\s+Function\s*\(/],
86
- ['a dynamic import() of a computed path', /(?<![.\w$])import\s*\(\s*(?!['"`]|\))/],
87
- // a string built at run time: import('./' + name), import(`./${name}`)
88
- ['a dynamic import() of a computed path', /(?<![.\w$])import\s*\(\s*(?:(['"])[^'"\n]*\1\s*\+|`[^`]*\$\{)/],
89
94
  ['importScripts()', /(?<![.\w$])importScripts\s*\(/],
90
95
  ];
96
+ /** esbuild's word for an import() it cannot bundle because its argument is not a plain string. */
97
+ const COMPUTED_IMPORT = 'unsupported-dynamic-import';
98
+ /** A string literal in printed code: "…" or '…' (never a template: the host's parse reads only these as plain). */
99
+ const STRING = /^\s*("(?:[^"\\\n]|\\.)*"|'(?:[^'\\\n]|\\.)*')\s*,/;
100
+ const unquote = q => {
101
+ try {
102
+ return JSON.parse(q[0] === '"' ? q : `"${q.slice(1, -1).replace(/\\'/g, "'").replace(/"/g, '\\"')}"`);
103
+ } catch {
104
+ return null;
105
+ }
106
+ };
107
+ /**
108
+ * An import() esbuild did not bundle (its warning, `w`), read at its place: → {module} when its first argument is a
109
+ * plain string after all (an import() with a second argument esbuild does not take, such as `{}`), else {computed}.
110
+ */
111
+ function unbundled(w) {
112
+ const text = w.location?.lineText ?? '',
113
+ col = w.location?.column ?? 0;
114
+ const at = text.slice(col).startsWith('import') ? col : text.lastIndexOf('import', col);
115
+ const m = at >= 0 ? /^import\s*\(/.exec(text.slice(at)) : null;
116
+ const lit = m ? STRING.exec(text.slice(at + m[0].length)) : null;
117
+ const value = lit ? unquote(lit[1]) : null;
118
+ return typeof value === 'string' ? { module: value } : { computed: true };
119
+ }
120
+
121
+ /**
122
+ * The imports a bundle (its bytes) still has, read by esbuild's parser as xgames.fun's parse reads them: → {modules:
123
+ * Set of what is imported (statically, as a re-export or by import() of a plain string, as written), computed: the
124
+ * number of import() calls of anything else}. null when it cannot be read (then nothing is taken off what the build saw).
125
+ */
126
+ export async function bundleImports(build, code) {
127
+ const modules = new Set();
128
+ let computed = 0;
129
+ try {
130
+ const r = await build({
131
+ stdin: { contents: new TextDecoder().decode(code), loader: 'js', sourcefile: BUNDLE_NAME },
132
+ bundle: true,
133
+ write: false,
134
+ format: 'esm',
135
+ platform: 'neutral',
136
+ logLevel: 'silent',
137
+ logOverride: { [COMPUTED_IMPORT]: 'warning' },
138
+ plugins: [
139
+ {
140
+ name: 'gf-bundle-imports',
141
+ setup(b) {
142
+ b.onResolve({ filter: /.*/ }, a => {
143
+ if (a.kind === 'import-statement' || a.kind === 'dynamic-import') modules.add(a.path);
144
+ return { path: a.path, external: true };
145
+ });
146
+ },
147
+ },
148
+ ],
149
+ });
150
+ for (const w of r.warnings)
151
+ if (w.id === COMPUTED_IMPORT) {
152
+ const u = unbundled(w);
153
+ if (u.computed) computed++;
154
+ else modules.add(u.module);
155
+ }
156
+ } catch {
157
+ return null;
158
+ }
159
+ return { modules, computed };
160
+ }
161
+ /** What xgames.fun refuses, said where it is: a socket module. */
162
+ const socketWords = (where, mod) =>
163
+ `${where} imports ${mod}, which opens raw network connections or loads modules by name: xgames.fun refuses a server that can (a game's server reaches the network with fetch() only). Take out what needs it (a database driver, a mail or chat client) and call a service over fetch() instead`;
164
+ /** … and an import() of a name made at run time. */
165
+ const computedWords = where =>
166
+ `${where} imports a module named at run time (import() of something other than a plain string): xgames.fun refuses it, since it could load any module. Import each module by its own name (import('./levels/forest.js')), or a path esbuild can list (import(\`./levels/\${name}.js\`) bundles every file there)`;
91
167
 
92
168
  const sha256 = b => createHash('sha256').update(b).digest('hex');
93
169
  const lineAt = (text, i) => text.slice(0, i).split('\n').length;
@@ -135,7 +211,6 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
135
211
  const root = path.resolve(dir);
136
212
  const cfg = await readServerConfig(root);
137
213
  if (!cfg.has) return { has: false, problems: cfg.problems };
138
- const nodeCompat = cfg.compatibilityFlags.includes('nodejs_compat');
139
214
  const out = {
140
215
  has: true,
141
216
  ok: false,
@@ -158,6 +233,27 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
158
233
  const { build } = await loadEsbuild();
159
234
  if (cfg.stock) return bundleStock(build, out, { minify });
160
235
  const refusedImports = [];
236
+ const rel = p => path.relative(root, path.resolve(root, p)).replace(/\\/g, '/');
237
+ // what the build saw import a socket module or a Node built-in, by module: [the words for each file that has it].
238
+ // Only a module the bundle still imports refuses it (below): esbuild leaves out what nothing uses.
239
+ const seen = new Map();
240
+ const saw = (mod, line) => {
241
+ const lines = seen.get(mod) ?? [];
242
+ if (!lines.includes(line)) lines.push(line);
243
+ seen.set(mod, lines);
244
+ };
245
+ const sockets = {
246
+ name: 'gf-server-sockets',
247
+ setup(b) {
248
+ b.onResolve({ filter: /.*/ }, args => {
249
+ if (args.kind !== 'import-statement' && args.kind !== 'dynamic-import') return undefined;
250
+ const mod = socketModule(args.path);
251
+ if (!mod) return undefined;
252
+ saw(mod, socketWords(rel(args.importer), mod));
253
+ return { path: args.path, external: true };
254
+ });
255
+ },
256
+ };
161
257
  // compiled wasm: its own module, never inlined (the runtime can't compile wasm from bytes)
162
258
  const wasmFiles = new Map(); // name → absolute path
163
259
  const wasm = {
@@ -182,36 +278,31 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
182
278
  });
183
279
  },
184
280
  };
185
- // Node's built-ins: only as nodejs_compat gives them (§3), named in words, never a bare name esbuild cannot find
281
+ // Node's built-ins: a game's server has none (xgames.fun offers no nodejs_compat), named in words, never a bare name
282
+ // esbuild cannot find
186
283
  const guard = {
187
284
  name: 'gf-server-node',
188
285
  setup(b) {
189
286
  b.onResolve({ filter: /^node:|^[a-z_]+(\/[a-z_]+)?$/ }, args => {
190
287
  const bare = args.path.replace(/^node:/, '');
191
288
  if (!args.path.startsWith('node:') && !NODE_BUILTINS.has(bare)) return undefined; // a package of the game's own
192
- const where = path.relative(root, args.importer).replace(/\\/g, '/');
193
- if (!args.path.startsWith('node:'))
194
- refusedImports.push(
195
- `${where} imports "${args.path}": write "node:${bare}"${nodeCompat ? '' : ', and turn on nodejs_compat in gamesfun.json (server.compatibilityFlags: ["nodejs_compat"])'}`,
196
- );
197
- else if (!nodeCompat)
198
- refusedImports.push(
199
- `${where} imports ${args.path}: Node's built-ins need nodejs_compat (gamesfun.json server.compatibilityFlags: ["nodejs_compat"]); or use the Web APIs (fetch, crypto.subtle, URL, TextEncoder)`,
200
- );
201
- else if (NODE_REFUSED.includes(bare))
202
- refusedImports.push(
203
- `${where} imports ${args.path}: the hosted runtime (a Worker) does not have it. Keep data in env.GF (saves, scores) or a Durable Object's storage, and use the Web APIs (fetch, crypto.subtle, URL)`,
204
- );
205
- return { path: args.path.startsWith('node:') ? args.path : `node:${bare}`, external: true };
289
+ saw(
290
+ `node:${bare}`,
291
+ `${rel(args.importer)} imports ${JSON.stringify(args.path)}, one of Node's built-in modules, which a game's server on xgames.fun does not have. Use the Web APIs a Worker has: fetch, crypto.subtle and crypto.getRandomValues, URL, TextEncoder and TextDecoder, Response; keep data in env.GF (saves, scores), a database or a Durable Object's storage`,
292
+ );
293
+ return { path: `node:${bare}`, external: true };
206
294
  });
207
295
  },
208
296
  };
209
297
  let result;
210
298
  try {
211
299
  result = await build({
212
- ...buildOptions(path.join(root, cfg.entry), { minify, nodeCompat }),
300
+ ...buildOptions(path.join(root, cfg.entry), { minify }),
213
301
  absWorkingDir: root,
214
- plugins: [guard, wasm],
302
+ plugins: [sockets, guard, wasm],
303
+ // an import() esbuild cannot bundle (its argument is not a plain string) stays in the bundle, where xgames.fun
304
+ // refuses it: said as a warning with its file and line, in a package's code too
305
+ logOverride: { [COMPUTED_IMPORT]: 'warning' },
215
306
  });
216
307
  } catch (e) {
217
308
  for (const m of e.errors ?? [{ text: e.message }])
@@ -220,6 +311,26 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
220
311
  );
221
312
  return out;
222
313
  }
314
+ // the bundle as it would be sent, read as xgames.fun reads it: only what it still imports is refused
315
+ const kept = await bundleImports(build, result.outputFiles[0].contents);
316
+ const keptMods = kept ? new Set([...kept.modules].map(m => socketModule(m) ?? m)) : null;
317
+ // a plain-string import() esbuild did not take (a second argument it does not read) is an import of that module
318
+ const computedAt = [];
319
+ for (const w of result.warnings ?? [])
320
+ if (w.id === COMPUTED_IMPORT) {
321
+ const where = w.location ? `${rel(w.location.file)}:${w.location.line}` : 'server/';
322
+ const u = unbundled(w);
323
+ if (u.computed) computedAt.push(where);
324
+ else if (socketModule(u.module)) saw(socketModule(u.module), socketWords(where, socketModule(u.module)));
325
+ }
326
+ for (const [mod, lines] of seen) if (!keptMods || keptMods.has(mod)) refusedImports.push(...lines);
327
+ if (kept) {
328
+ // a module the bundle imports that the build never named (not expected; said for the bundle)
329
+ for (const mod of keptMods)
330
+ if (socketModule(mod) && !seen.has(mod)) refusedImports.push(socketWords(`the server bundle`, mod));
331
+ if (kept.computed)
332
+ refusedImports.push(...(computedAt.length ? computedAt : ['the server bundle']).map(computedWords));
333
+ } else refusedImports.push(...computedAt.map(computedWords));
223
334
  out.problems.push(...refusedImports);
224
335
  const inputs = Object.keys(result.metafile.inputs).filter(p => !/^(cloudflare|node):/.test(p));
225
336
  for (const p of inputs) {
@@ -320,7 +431,7 @@ export async function bundleServer(dir, { limits = LIMITS, minify = true } = {})
320
431
  // here would see it. Read in an unminified pass, where each export's declaration is its own line.
321
432
  const others = out.exports.filter(n => n !== 'default' && !cfg.objects.some(o => o.class === n));
322
433
  if (others.length) {
323
- const plain = await plainExports(build, path.join(root, cfg.entry), root, nodeCompat).catch(() => []);
434
+ const plain = await plainExports(build, path.join(root, cfg.entry), root).catch(() => []);
324
435
  for (const { name, value } of plain.filter(x => others.includes(x.name)))
325
436
  out.problems.push(
326
437
  `${cfg.entry} exports ${name} = ${value}: the hosted runtime takes only functions, classes and objects as a server's exports, and refuses to start a server with a plain value. Keep ${name} unexported (or in a module the server imports)`,