@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.
- package/README.md +139 -17
- package/commands/dev.mjs +31 -10
- package/commands/login.mjs +22 -11
- package/commands/logout.mjs +2 -0
- package/commands/loot.mjs +13 -4
- package/commands/publish.mjs +4 -0
- package/commands/review.mjs +106 -0
- package/docs/SERVER.md +120 -32
- package/lib/add.mjs +1 -0
- package/lib/check.mjs +1 -0
- package/lib/cli.mjs +2 -0
- package/lib/client/gamesfun.js +348 -9
- package/lib/client/godot/gamesfun.gd +10 -1
- package/lib/client/server/0001_init.sql +1 -1
- package/lib/client/server/database.js +2 -1
- package/lib/client/server/gf-db.js +6 -2
- package/lib/client/server/rooms.js +1 -0
- package/lib/client/server/vouchers.js +15 -8
- package/lib/client/unity/GamesFun.cs +1 -1
- package/lib/dev/files.mjs +102 -5
- package/lib/dev/gf-local.js +4 -0
- package/lib/dev/index.mjs +49 -10
- package/lib/dev/limits.mjs +55 -24
- package/lib/dev/live.mjs +162 -0
- package/lib/dev/plans.mjs +101 -0
- package/lib/dev/platform.mjs +490 -138
- package/lib/economy.mjs +12 -0
- package/lib/login-reach.mjs +184 -0
- package/lib/publish/adopt.mjs +11 -0
- package/lib/publish/index.mjs +16 -7
- package/lib/publish/names.mjs +128 -1
- package/lib/publish/scan.mjs +25 -1
- package/lib/publish/tree.mjs +9 -1
- package/lib/review.mjs +1166 -0
- package/lib/secret-patterns.mjs +36 -26
- package/lib/server/bundle.mjs +139 -28
- package/lib/server/contract.mjs +95 -12
- package/package.json +2 -2
- package/skills/bring-your-own-game/SKILL.md +58 -9
package/lib/secret-patterns.mjs
CHANGED
|
@@ -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
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
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:
|
|
10
|
-
export const MAX_MATCH =
|
|
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,
|
|
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,
|
|
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,
|
|
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,
|
|
26
|
-
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,
|
|
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,
|
|
29
|
-
['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,
|
|
30
|
-
['Supabase access token', /\bsbp_[A-Za-z0-9]{20,
|
|
31
|
-
['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,
|
|
32
|
-
['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,
|
|
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,
|
|
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,
|
|
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,
|
|
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?:\/\/[
|
|
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,
|
|
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')
|
|
55
|
-
|
|
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
|
-
|
|
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
|
];
|
package/lib/server/bundle.mjs
CHANGED
|
@@ -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
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
/**
|
|
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:
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
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
|
|
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)`,
|
package/lib/server/contract.mjs
CHANGED
|
@@ -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
|
|
25
|
-
//
|
|
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
|
-
/**
|
|
38
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
|
|
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({
|
|
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 (
|
|
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
|
|
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.
|
|
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.
|
|
59
|
+
"@xgamesfun/solana": "0.26.0",
|
|
60
60
|
"esbuild": "0.28.2",
|
|
61
61
|
"playwright-core": "1.63.0"
|
|
62
62
|
},
|