@xgamesfun/xcore 0.2.1 → 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.
@@ -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)`,
@@ -21,8 +21,15 @@
21
21
  // env.<binding> a Durable Object namespace for each declared object, SQLite storage
22
22
  // env.GF_GAME_ID, env.GF_SLUG, env.GF_CONTRACT plain text
23
23
  // env.<database.binding> the game's D1 database, when gamesfun.json has a database (env.DB)
24
- // Runtime: Workers, compatibility date 2025-06-01; nodejs_compat only when gamesfun.json asks for it
25
- // Outbound: fetch() reaches only backend.json's origins and xgames.fun's (hosted; not enforced on this machine)
24
+ // Runtime: Workers, compatibility date 2025-06-01; no compatibility flags (nodejs_compat is not offered: the host
25
+ // refuses a publish that asks for it)
26
+ // Outbound: backend.json's origins (and xgames.fun's) are the list for the fetch() calls the entry makes while it
27
+ // answers a request (hosted; not enforced on this machine); nothing else is promised of it. No number of
28
+ // calls out a request is promised. A bundle that can open a raw socket is refused (SOCKET_MODULES,
29
+ // ./bundle.mjs).
30
+ // Off: an unpublished, taken-down or capped game's server is switched off: every request answers 451
31
+ // {error: 'stopped', reason}, alarms lapse and sockets close (a running room within about five minutes);
32
+ // its Durable Objects' data is kept, and it comes back when the game is published again or the cap lifts.
26
33
  import { createHash } from 'node:crypto';
27
34
  import { existsSync } from 'node:fs';
28
35
  import { readdir, readFile } from 'node:fs/promises';
@@ -34,8 +41,37 @@ export const DEFAULT_ENTRY = 'server/index.js';
34
41
  export const SERVER_DIR = 'server';
35
42
  /** The runtime the hosted script runs on (xgames.fun's upload and its local parity use these). */
36
43
  export const COMPATIBILITY_DATE = '2025-06-01';
37
- /** The only compatibility flag a game may ask for (gamesfun.json server.compatibilityFlags). */
38
- export const ALLOWED_FLAGS = Object.freeze(['nodejs_compat']);
44
+ /**
45
+ * The compatibility flags a game may ask for (gamesfun.json server.compatibilityFlags): none. nodejs_compat is not
46
+ * offered: with Node's built-in modules a server can open raw network connections in ways a check at publish cannot
47
+ * see, so xgames.fun refuses a publish that asks for it.
48
+ */
49
+ export const ALLOWED_FLAGS = Object.freeze([]);
50
+ /** What a gamesfun.json that asks for nodejs_compat is told. */
51
+ export const NODE_COMPAT_REFUSED = `${CONFIG_FILE} server.compatibilityFlags asks for "nodejs_compat", which xgames.fun does not offer: with Node's built-in modules a server can open raw network connections, so a publish that asks for it is refused. Take it out ("compatibilityFlags": []) and use the Web APIs a Worker has (fetch, crypto.subtle, URL, TextEncoder, Response); a package that needs node:… does not run on xgames.fun`;
52
+ /**
53
+ * Modules a game's server may not import (statically, as a re-export or with import()), with or without `node:`: they
54
+ * open raw network connections (TCP, TLS, UDP, HTTP/2 over a socket) or load other modules by name. The host parses
55
+ * every module of the bundle and refuses one that imports them, and one with an import() of anything but a plain string
56
+ * (it could name any module at run time). A game's server reaches the network with fetch() only.
57
+ */
58
+ export const SOCKET_MODULES = Object.freeze([
59
+ 'cloudflare:sockets',
60
+ 'node:net',
61
+ 'node:tls',
62
+ 'node:dgram',
63
+ 'node:http2',
64
+ 'node:module',
65
+ 'node:child_process',
66
+ ]);
67
+ const SOCKET_NAMES = new Set(SOCKET_MODULES.flatMap(m => (m.startsWith('node:') ? [m, m.slice(5)] : [m])));
68
+ /** The socket module an import names, as the runtime would resolve it (no query or fragment, any case); else null. */
69
+ export const socketModule = spec => {
70
+ const s = String(spec)
71
+ .replace(/[?#].*$/, '')
72
+ .toLowerCase();
73
+ return SOCKET_NAMES.has(s) ? (s.includes(':') ? s : `node:${s}`) : null;
74
+ };
39
75
  export const ROUTES = '/api/*';
40
76
  /** The keys gamesfun.json → server takes. */
41
77
  export const SERVER_KEYS = Object.freeze(['main', 'objects', 'routes', 'compatibilityFlags']);
@@ -57,8 +93,8 @@ export const reservedBinding = b => b === 'GF' || b.startsWith('GF_');
57
93
  export const API_PREFIX = '/api/';
58
94
  export const PLATFORM_PREFIX = '/platform/v1/';
59
95
  /**
60
- * Node built-ins nodejs_compat does not give a Worker at COMPATIBILITY_DATE (or gives only as a stub that throws):
61
- * an import of one is refused at bundle time with words, not left to fail on the first request hosted.
96
+ * Node built-ins nodejs_compat does not give a Worker at COMPATIBILITY_DATE (or gives only as a stub that throws). Kept
97
+ * for code that reads it: no game's server gets Node's built-ins now (ALLOWED_FLAGS), so every node: import is refused.
62
98
  */
63
99
  export const NODE_REFUSED = Object.freeze([
64
100
  'child_process',
@@ -82,7 +118,13 @@ export const NODE_REFUSED = Object.freeze([
82
118
  /**
83
119
  * env.GF, the platform binding (a Workers RPC service, entrypoint GameServices; locally ./gf-local.js). Every answer is
84
120
  * {ok: true, …} or {ok: false, code, why}; a refusal is an answer, never a throw.
85
- * verifyPlayer(token) → {ok, sub, wallet | null, x: {id, handle} | null, lvl, exp (unix s)}
121
+ * verifyPlayer(token) → {ok, sub, wallet | null, x: {id, handle} | null, lvl, exp (unix s),
122
+ * (sub: the player's account, x:<X id>, or g:<id> for a Google account, an id
123
+ * different in every game; never the wallet, which is its own claim and may be
124
+ * null or change: data kept by wallet reads `wallet`)
125
+ * banned?: {kind: 'ban' | 'hide', until: ms | null} | null}
126
+ * (banned: the creator's ban on the player from the console; a token made
127
+ * before the ban still verifies, so a server refuses kind 'ban' itself)
86
128
  * saves.get(player, key = 'main') → {ok, value | null, updatedAt}
87
129
  * saves.put(player, key, value) → {ok, bytes} (a JSON value, 64 KB; 16 keys a player)
88
130
  * saves.delete(player, key) → {ok, deleted}
@@ -93,7 +135,8 @@ export const NODE_REFUSED = Object.freeze([
93
135
  * 'server': the page's /platform/v1/scores submit is refused, 403
94
136
  * server-only, and what the page wrote before is removed; board may be a
95
137
  * pattern, 'daily-*' or '*': the rule for every board it names, to come too)
96
- * scores.drop(board) → {ok, dropped, removed} (the board gone: its place among the 16 free)
138
+ * scores.drop(board) → {ok, dropped, removed} (the board gone: its place among the plan's
139
+ * boards free again: 8 on Free, 16 on Creator and Scale, PLAN_LIMITS)
97
140
  * scores.top(board, {limit?, offset?}) → {ok, order, writer, entries: [{rank, player, name, score, at}]}
98
141
  * scores.best(board, player) → {ok, entry: {rank, score, name, at} | null}
99
142
  * scores.remove(board, player) → {ok, removed}
@@ -199,10 +242,49 @@ export const fileType = p => BY_EXTENSION[/\.([a-z0-9]+)$/i.exec(p)?.[1]?.toLowe
199
242
 
200
243
  /** Server code that calls env.GF.files (its sources, comments left out), which needs "files" in server.features. */
201
244
  export const usesFiles = code => /\bGF\s*\.\s*files\b|\bfiles\b[^;{}]*\}\s*=\s*[\w.]*\bGF\b/.test(code);
202
- /** The platform's per-game limits (xgames.fun platform/limits.mjs), the same here so a game fits both. */
245
+ const MB = 1024 * 1024;
246
+ const frozen = o => Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, Object.freeze(v)])));
247
+ /**
248
+ * The platform's numbers by the game's plan (xgames.fun's plan table; README "Its plan", `xcore plan` shows a published
249
+ * game's own): CPU a request, saves, scores, reads a minute, project files. A game is on Free unless it is in a paid
250
+ * slot (Creator) or granted Scale.
251
+ */
252
+ export const PLAN_LIMITS = Object.freeze({
253
+ free: frozen({
254
+ cpuMs: 50,
255
+ saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 10 * MB, writesPerMinute: 600 },
256
+ scores: { boards: 8, rules: 16, entriesPerBoard: 10_000, submitsPerMinute: 600 },
257
+ reads: { perMinute: 1_200 },
258
+ files: { bytes: 100 * MB, fileBytes: 25 * MB },
259
+ }),
260
+ creator: frozen({
261
+ cpuMs: 200,
262
+ saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 1024 * MB, writesPerMinute: 12_000 },
263
+ scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 6_000 },
264
+ reads: { perMinute: 12_000 },
265
+ files: { bytes: 10 * 1024 * MB, fileBytes: 25 * MB },
266
+ }),
267
+ scale: frozen({
268
+ cpuMs: 1_000,
269
+ saves: { valueBytes: 64 * 1024, keysPerPlayer: 16, gameBytes: 10 * 1024 * MB, writesPerMinute: 60_000 },
270
+ scores: { boards: 16, rules: 32, entriesPerBoard: 1_000_000, submitsPerMinute: 30_000 },
271
+ reads: { perMinute: 60_000 },
272
+ files: { bytes: 100 * 1024 * MB, fileBytes: 25 * MB },
273
+ }),
274
+ });
275
+ /**
276
+ * The platform's per-game limits that a single number stands for: the same on every plan, and, where a plan sets it
277
+ * (boards, rules), Free's, the plan every game starts on (PLAN_LIMITS has the others).
278
+ */
203
279
  export const PLATFORM_LIMITS = Object.freeze({
204
280
  saves: Object.freeze({ playerChars: 128, valueBytes: 64 * 1024, keysPerPlayer: 16 }),
205
- scores: Object.freeze({ boards: 16, rules: 32, nameChars: 32, perPlayerEveryMs: 1000, topMax: 100 }),
281
+ scores: Object.freeze({
282
+ boards: PLAN_LIMITS.free.scores.boards,
283
+ rules: PLAN_LIMITS.free.scores.rules,
284
+ nameChars: 32,
285
+ perPlayerEveryMs: 1000,
286
+ topMax: 100,
287
+ }),
206
288
  loot: Object.freeze({ winners: 16 }),
207
289
  });
208
290
  /** A save key or a board name. */
@@ -671,9 +753,10 @@ export async function readServerConfig(dir) {
671
753
  `${CONFIG_FILE} declares ${objects.length} Durable Object classes; a game's server may have ${LIMITS.objects}`,
672
754
  );
673
755
  const flags = srv.compatibilityFlags ?? [];
674
- if (!Array.isArray(flags) || flags.some(f => !ALLOWED_FLAGS.includes(f)))
756
+ if (Array.isArray(flags) && flags.includes('nodejs_compat')) problems.push(NODE_COMPAT_REFUSED);
757
+ else if (!Array.isArray(flags) || flags.some(f => !ALLOWED_FLAGS.includes(f)))
675
758
  problems.push(
676
- `${CONFIG_FILE} server.compatibilityFlags may hold only ${ALLOWED_FLAGS.map(f => JSON.stringify(f)).join(', ')}`,
759
+ `${CONFIG_FILE} server.compatibilityFlags must be an empty list: remove ${Array.isArray(flags) ? flags.map(f => JSON.stringify(f)).join(', ') : JSON.stringify(flags)} ("compatibilityFlags": []), since xgames.fun runs every game's server with none`,
677
760
  );
678
761
  const database = await readDatabaseConfig(dir, config, { objects, own: true });
679
762
  problems.push(...database.problems);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xgamesfun/xcore",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "xgames.fun from the command line: bring a web game you already made, check it as xgames.fun hosts it, give it a backend and a Solana economy, and publish it at a link anyone can play.",
5
5
  "keywords": [
6
6
  "game",
@@ -56,7 +56,7 @@
56
56
  "test": "node --test \"tests/**/*.test.mjs\""
57
57
  },
58
58
  "dependencies": {
59
- "@xgamesfun/solana": "0.25.0",
59
+ "@xgamesfun/solana": "0.26.0",
60
60
  "esbuild": "0.28.2",
61
61
  "playwright-core": "1.63.0"
62
62
  },