@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/lib/economy.mjs CHANGED
@@ -35,6 +35,18 @@ export function solanaCommand(env = process.env) {
35
35
  return [process.execPath, path.join(root, bin)];
36
36
  }
37
37
 
38
+ /** Does the installed package's command have `command` (a version from before it says it has none)? Read from its
39
+ * command's own source, so nothing runs. */
40
+ export function solanaHas(command, env = process.env) {
41
+ const xsolana = solanaCommand(env);
42
+ if (!xsolana) return false;
43
+ try {
44
+ return new RegExp(`['"]${command}['"]`).test(readFileSync(xsolana[1], 'utf8'));
45
+ } catch {
46
+ return false;
47
+ }
48
+ }
49
+
38
50
  // ---- The page client in a game ---------------------------------------------------------------------------------------
39
51
  // A published page has no node_modules: the package's page side (@xgamesfun/solana/play and every file it imports, all
40
52
  // relative plain ES modules with no dependency) is copied into a game with an economy as gf/loot/. Its record
@@ -0,0 +1,184 @@
1
+ // What an `xcore login` reaches. xgames.fun's approval page asks the developer which games the command line may work
2
+ // on: the games they tick (and any game they publish as new from that sign-in), or their whole account. The token
3
+ // exchange answers which ({scope: 'games', games: [ids]} or {scope: 'account'}); this module says it after sign-in,
4
+ // keeps it beside the sign-in's public record (kit-reach.json in the config folder, keyed by the token's id: never a
5
+ // secret), and words a refusal for a game the sign-in was not given (xgames.fun's 'not-granted', 403) as what it is,
6
+ // with what to do. A sign-in's games grow after it is made (a game it publishes as new joins them), so `xcore login
7
+ // --status` and a refusal ask xgames.fun for them again (GET /api/kit/plan lists the games the sign-in reaches) and
8
+ // keep the answer; when xgames.fun cannot be asked, the saved list stands, said to be possibly out of date.
9
+ import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { configDir, listEntries } from './credentials.mjs';
12
+ import { call, credentialKey, login, savedLogin, storeWords, studioOrigin } from './publish/client.mjs';
13
+ import { CMD } from './command.mjs';
14
+
15
+ const reachFile = env => path.join(configDir({ env }), 'kit-reach.json');
16
+ async function readAll(env) {
17
+ try {
18
+ const v = JSON.parse(await readFile(reachFile(env), 'utf8'));
19
+ return v && typeof v === 'object' && !Array.isArray(v) ? v : {};
20
+ } catch {
21
+ return {};
22
+ }
23
+ }
24
+ async function writeAll(env, all) {
25
+ const file = reachFile(env);
26
+ await mkdir(path.dirname(file), { recursive: true, mode: 0o700 });
27
+ await writeFile(`${file}.${process.pid}.tmp`, JSON.stringify(all, null, 2) + '\n', { mode: 0o600 });
28
+ await rename(`${file}.${process.pid}.tmp`, file);
29
+ }
30
+
31
+ /** The reach xgames.fun gave in a token exchange's answer: {scope, games: [id] | null}, or null when it says none. */
32
+ function reachOf(answer) {
33
+ if (answer?.scope === 'account') return { scope: 'account', games: null };
34
+ if (answer?.scope === 'games')
35
+ return { scope: 'games', games: Array.isArray(answer.games) ? answer.games.map(String) : [] };
36
+ return null;
37
+ }
38
+
39
+ /**
40
+ * `xcore login` (../lib/publish/client.mjs login, with the same options) that also learns what the sign-in reaches,
41
+ * names those games (GET /api/kit/plan lists the games the sign-in reaches, with their titles; when it does not answer,
42
+ * the ids stand) and keeps it for --status. → login's answer and {scope: 'games' | 'account' | null, games: [{id,
43
+ * title, slug}] | null}
44
+ */
45
+ export async function loginWithReach({ origin = studioOrigin(), env = process.env, fetchImpl = fetch, ...opts } = {}) {
46
+ let exchanged = null;
47
+ const watching = async (url, init) => {
48
+ const res = await fetchImpl(url, init);
49
+ if (String(url) === `${origin}/api/kit/token` && init?.method === 'POST' && res.ok)
50
+ exchanged = await res
51
+ .clone()
52
+ .json()
53
+ .catch(() => null);
54
+ return res;
55
+ };
56
+ const r = await login({ origin, env, fetchImpl: watching, ...opts });
57
+ const reach = reachOf(exchanged);
58
+ if (!reach) return { ...r, studio: origin, scope: null, games: null };
59
+ let games = null;
60
+ if (reach.scope === 'games') {
61
+ const titles = new Map();
62
+ if (reach.games.length)
63
+ try {
64
+ for (const g of gamesOf(await call(origin, '/api/kit/plan', { token: exchanged.token, fetchImpl })))
65
+ titles.set(g.id, g);
66
+ } catch {
67
+ /* the ids stand */
68
+ }
69
+ games = reach.games.map(id => titles.get(id) ?? { id, title: null, slug: null });
70
+ }
71
+ const all = await readAll(env);
72
+ all[credentialKey(origin)] = { tokenId: exchanged?.tokenId ?? null, scope: reach.scope, games };
73
+ await writeAll(env, all);
74
+ return { ...r, studio: origin, scope: reach.scope, games };
75
+ }
76
+
77
+ /** What this computer's sign-in to `origin` reaches, as kept at sign-in: {scope, games} or null (none kept). */
78
+ export async function readReach(origin = studioOrigin(), { env = process.env } = {}) {
79
+ const k = credentialKey(origin);
80
+ const kept = (await readAll(env))[k];
81
+ const entry = (await listEntries({ env }))[k];
82
+ // only for the sign-in it was kept for: another login since (from an older xcore) leaves it unknown
83
+ if (!kept || !entry || (kept.tokenId ?? null) !== (entry.tokenId ?? null)) return null;
84
+ return { scope: kept.scope, games: kept.games ?? null };
85
+ }
86
+
87
+ /** The games GET /api/kit/plan lists for the sign-in, as {id, title, slug}. */
88
+ const gamesOf = plan =>
89
+ (Array.isArray(plan?.games) ? plan.games : [])
90
+ .filter(g => g && typeof g.id === 'string')
91
+ .map(g => ({
92
+ id: g.id,
93
+ title: typeof g.title === 'string' ? g.title : null,
94
+ slug: typeof g.slug === 'string' ? g.slug : null,
95
+ }));
96
+
97
+ /**
98
+ * What this computer's sign-in to `origin` reaches now: its scope as given at sign-in (a sign-in's scope never
99
+ * changes), and for one given games, those games as xgames.fun lists them now, kept for next time. → {scope, games,
100
+ * checked: true} (xgames.fun answered), {scope, games, checked: false} (the saved copy: xgames.fun could not be asked),
101
+ * or null (not known: no sign-in, or one made by an older xcore).
102
+ */
103
+ export async function currentReach(origin = studioOrigin(), { env = process.env, fetchImpl = fetch } = {}) {
104
+ const saved = await readReach(origin, { env });
105
+ if (!saved) return null;
106
+ if (saved.scope !== 'games') return { ...saved, checked: true };
107
+ try {
108
+ const s = await savedLogin(origin, { env });
109
+ if (!s?.token) throw new Error('no token');
110
+ const plan = await call(origin, '/api/kit/plan', { token: s.token, fetchImpl });
111
+ // an answer that is not a list of games (a page, {}, games of another shape) is no answer: the saved copy stands
112
+ if (!Array.isArray(plan?.games) || !plan.games.every(x => x && typeof x === 'object' && typeof x.id === 'string'))
113
+ throw new Error('not a list of games');
114
+ const games = gamesOf(plan);
115
+ const all = await readAll(env);
116
+ const k = credentialKey(origin);
117
+ if (all[k]) {
118
+ all[k] = { ...all[k], games };
119
+ await writeAll(env, all);
120
+ }
121
+ return { scope: 'games', games, checked: true };
122
+ } catch {
123
+ return { ...saved, checked: false };
124
+ }
125
+ }
126
+
127
+ /** Forget what the sign-in to `origin` reaches (`xcore logout`). */
128
+ export async function forgetReach(origin = studioOrigin(), { env = process.env } = {}) {
129
+ const all = await readAll(env);
130
+ if (!(credentialKey(origin) in all)) return;
131
+ delete all[credentialKey(origin)];
132
+ await writeAll(env, all);
133
+ }
134
+
135
+ const gameName = g => (g.title ? `${g.title}${g.slug ? ` (${g.slug})` : ''}` : g.slug || g.id);
136
+ const MAYBE_OLD =
137
+ ' (xgames.fun could not be asked just now, so this is the list saved at sign-in or at the last check, and may be out of date.)';
138
+ /** The reach in a sentence: "This sign-in reaches …". '' when it is not known. `checked: false`: said to be saved. */
139
+ export function reachWords(reach) {
140
+ if (reach?.scope === 'account') return 'This sign-in reaches your whole account: every game, now and later.';
141
+ if (reach?.scope !== 'games') return '';
142
+ const games = reach.games ?? [];
143
+ const old = reach.checked === false ? MAYBE_OLD : '';
144
+ if (!games.length)
145
+ return `This sign-in reaches none of your games yet: a game you publish as new from here is added to it. To work on a game you have, run ${CMD} login again and tick it.${old}`;
146
+ return (
147
+ `This sign-in reaches ${games.length} of your games: ${games.map(gameName).join(', ')}. ` +
148
+ `A game you publish as new from here is added to it; for another, run ${CMD} login again and tick it.${old}`
149
+ );
150
+ }
151
+
152
+ /** What `xcore login` prints once signed in. */
153
+ export function loginWords(r) {
154
+ const reach = reachWords(r);
155
+ return (
156
+ `Signed in to ${r.studio ?? studioOrigin()} as ${r.email}.${reach ? ` ${reach}` : ''} The token is kept in ` +
157
+ `${storeWords(r.store)} until ${new Date(r.expiresAt).toISOString().slice(0, 10)}. Next: ${CMD} publish`
158
+ );
159
+ }
160
+
161
+ /**
162
+ * A refusal for a game the sign-in was not given (code 'not-granted'), in plain words with what to do, naming what the
163
+ * sign-in does reach when that is known. The error is changed in place (message, next). → the error
164
+ */
165
+ export async function explainNotGranted(e, { env = process.env } = {}) {
166
+ let reach = null;
167
+ try {
168
+ reach = await currentReach(studioOrigin(env), { env });
169
+ } catch {
170
+ /* unknown */
171
+ }
172
+ const has =
173
+ reach?.scope === 'games'
174
+ ? reach.games?.length
175
+ ? ` This sign-in reaches: ${reach.games.map(gameName).join(', ')}.`
176
+ : ' This sign-in reaches none of your games yet.'
177
+ : '';
178
+ const old = reach?.scope === 'games' && reach.checked === false ? MAYBE_OLD : '';
179
+ e.message =
180
+ `xgames.fun refused this: your sign-in (${CMD} login) was not given this game. Run ${CMD} login again and tick ` +
181
+ `this game on the approval page (or choose your whole account).${has}${old}`;
182
+ e.next = `${CMD} login`;
183
+ return e;
184
+ }
@@ -728,6 +728,7 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
728
728
 
729
729
  let rootPaths = 0,
730
730
  sw = false,
731
+ fullscreen = false, // a way to go full screen: the page's own button, an engine's, or GamesFun.fullscreen()
731
732
  devices = new Map(), // what it asks for → where
732
733
  callsApi = [];
733
734
  // a file the page loads by a path from the root: the first three are listed, the rest counted
@@ -903,6 +904,10 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
903
904
  });
904
905
  }
905
906
  if (!engine && /serviceWorker\s*\.\s*register\s*\(/.test(text)) sw = true;
907
+ if (
908
+ /\b(?:webkitR|r)equestFullscreen\b|\bfullscreen\s*\(|gf:fullscreen|window_set_mode|\bSetFullscreen\b/.test(text)
909
+ )
910
+ fullscreen = true;
906
911
  for (const [what, re] of [
907
912
  ['the camera or microphone', /getUserMedia\s*\(/],
908
913
  ['the location', /geolocation\s*\.\s*(getCurrentPosition|watchPosition)/],
@@ -977,6 +982,12 @@ export async function scanPage(out, { backend = [], limits = DEFAULT_LIMITS, pre
977
982
  what: `${at('index.html')} names no tab icon: browsers ask for /favicon.ico, and the live game's console shows a 404 for it`,
978
983
  fix: 'Add one beside the page and link it relatively: <link rel="icon" href="./icon.png"> (or put a favicon.ico at the root of what is published)',
979
984
  });
985
+ // most players come from a post on X or a phone, where the game first shows in a small frame
986
+ if (!fullscreen && files.some(f => /\.html?$/i.test(f.rel)))
987
+ warn.push({
988
+ what: 'it has no way to go full screen: in a post on X and on a phone the game first shows in a small frame',
989
+ fix: "Give it a full screen button that calls GamesFun.fullscreen() (gamesfun.js, xcore add signin): on xgames.fun the page around it goes full screen (or fills the screen in an app's own browser, X's among them), and elsewhere the browser does, or the game opens in a tab of its own. Or call the browser's element.requestFullscreen() from a click",
990
+ });
980
991
  if (sw)
981
992
  warn.push({
982
993
  what: 'it registers a service worker',
@@ -3,15 +3,17 @@
3
3
  // check): the command, the kit's MCP tool and its studio's Publish tab all come here.
4
4
  //
5
5
  // 1. signed in? ./client.mjs (xcore login)
6
- // 2. the files ./tree.mjs: refuses .env files and links; leaves out evidence/ and hidden files
6
+ // 2. the files ./tree.mjs: refuses .env files, links, programs, installers and archives (as xgames.fun
7
+ // does); leaves out evidence/ and hidden files
7
8
  // 3. no secrets ./scan.mjs (xgames.fun runs the same scan again on its side)
8
9
  // no reference names ./names.mjs: a file that names a game this one was made to look or play
9
10
  // like refuses it (spec.json goes up as a copy without them; the person's records keep them)
10
11
  // 4. its backend backend.json → origins (`xcore backend connect`): the only other sites the game may
11
12
  // talk to once published; xgames.fun's play host opens that game's CSP to exactly those
12
13
  // 5. its server code server/ (the gf-server-v1 contract, ../server/contract.mjs): bundled into one file and
13
- // checked (it builds, no code from text, no Node built-in a Worker lacks, no secrets, its
14
- // Durable Objects exported, its size); a problem refuses the publish. Sent only when
14
+ // checked (it builds, no code from text, no Node built-in, no raw sockets or import() of a
15
+ // name made at run time, no secrets, its Durable Objects exported, its size); a problem
16
+ // refuses the publish. Sent only when
15
17
  // xgames.fun says it takes server code (GET /api/kit/me → server.contracts has gf-server-v1);
16
18
  // otherwise the files go up alone, and the person is told. server/ itself is never a
17
19
  // public file.
@@ -19,7 +21,9 @@
19
21
  // the page check (../check.mjs). A game the kit made: the kit's six gates, its `check`; one
20
22
  // without it is refused here, never checked by less
21
23
  // 7. upload only the files (and the server bundle) xgames.fun does not have yet
22
- // 8. the version + link unlisted; an update is the next version at the same link
24
+ // 8. the version + link unlisted; an update is the next version at the same link. A first publish's link name is
25
+ // xgames.fun's, from the title; the kit says before it uploads when reserved words are left
26
+ // out of it (./names.mjs linkName)
23
27
  // A brought game (gamesfun.json → page, ./page.mjs; `xcore adopt`) is built first when it has a build, and its `out`
24
28
  // folder is what goes up (with its server/ bundle, as for any game); the files publish adds live in its `static` folder.
25
29
  // Signed in by `xcore login`, or in a GitHub Actions job by the job's own OIDC token (./oidc.mjs; actions/publish).
@@ -38,7 +42,7 @@ import {
38
42
  SCAN_BYTES,
39
43
  BACKEND_FILE,
40
44
  } from './scan.mjs';
41
- import { findNames, namesMessage } from './names.mjs';
45
+ import { findNames, linkNameNote, namesMessage } from './names.mjs';
42
46
  import { gate } from './gate.mjs';
43
47
  import { call, ownOrigins, signInState, studioOrigin } from './client.mjs';
44
48
  import { shown as shownDir } from '../games.mjs';
@@ -250,7 +254,10 @@ export async function preflight(dir, { origin = studioOrigin() } = {}) {
250
254
  const backend = backendOrigins(config, { own: ownOrigins(origin) });
251
255
  problems.push(...backend.problems);
252
256
  const names = namesMessage(named);
257
+ // a game with no link here yet: the link name its first publish will get, when its title has words xgames.fun leaves out
258
+ const linked = (await readLinks(dir))[origin];
253
259
  return {
260
+ linkName: linked ? null : linkNameNote(built.title),
254
261
  built,
255
262
  hits,
256
263
  secrets,
@@ -386,7 +393,7 @@ export async function publishGame(
386
393
  progress(
387
394
  `Its economy's Solana calls go to the game's own RPC (server/rpc.js, api/rpc) once its server has XSOLANA_RPC_URL: ${CMD} secret set XSOLANA_RPC_URL (once; ${CMD} secret list shows whether it is set). Until then they use xgames.fun's shared relay.`,
388
395
  );
389
- // the server may call only backend.json's origins: the RPC's must be one
396
+ // the server's entry (where api/rpc runs) may call only backend.json's origins: the RPC's must be one
390
397
  const local = serverRpc(dir);
391
398
  if (local && !(pre.backend ?? []).includes(local.origin))
392
399
  progress(
@@ -394,7 +401,7 @@ export async function publishGame(
394
401
  );
395
402
  else if (!local && !(pre.backend ?? []).length)
396
403
  progress(
397
- `Worth fixing: ${BACKEND_FILE} names no origins, and a game's server may call only those: add your RPC's (https://host alone) to its "origins".`,
404
+ `Worth fixing: ${BACKEND_FILE} names no origins, and a game's server (its entry, where api/rpc runs) may call only those: add your RPC's (https://host alone) to its "origins".`,
398
405
  );
399
406
  } else {
400
407
  const rpc = solanaRpc(env, rpcFile ? { file: rpcFile } : undefined);
@@ -472,6 +479,8 @@ export async function publishGame(
472
479
  const links = await readLinks(dir),
473
480
  known = newGame ? null : (links[origin] ?? null);
474
481
  const name = title ?? built.title;
482
+ const linkWords = known ? null : linkNameNote(name);
483
+ if (linkWords) progress(linkWords);
475
484
  progress(
476
485
  `${known ? `Updating ${known.slug ? `/play/${known.slug}` : 'your game'}` : `Publishing “${name}”`} on ${origin}…`,
477
486
  );
@@ -13,7 +13,8 @@
13
13
  // name the person typed with a slip ("ultmate") still finds the real spelling (an edit or two, by the name's length).
14
14
  // A one-word name shorter than four letters is not matched (too many ordinary words). A multi-word name's first word
15
15
  // on its own, capitalised, is a warning, not a refusal: it is often an ordinary word ("frogs", "portal").
16
- // Pure (no Node imports): the kit's publish and a host's scan can share it.
16
+ // Pure (no Node imports): the kit's publish and a host's scan can share it. The link name a first publish gives a game
17
+ // (linkName, at the end) is here too.
17
18
 
18
19
  export const REDACTED = 'a reference game';
19
20
  const LEAD =
@@ -203,3 +204,129 @@ export function namesMessage(hits) {
203
204
  "Style is fair game; another game's name is not. Take the name out of the game's own words (titles, text, comments, file names), then publish again. The person's prompt and the look's records keep it on this machine."
204
205
  );
205
206
  }
207
+
208
+ // ---- A game's link name -----------------------------------------------------------------------------------------------
209
+ // The first publish gives a game its link name, from its title, and the game keeps it for good (through renames): it
210
+ // plays at https://<name>-play.xgames.fun. xgames.fun makes the name, never the kit; these are its rules, so the kit can
211
+ // say before anything is sent what the name will be. A new name never carries a sign-in, wallet, support or brand word
212
+ // (a game titled "Phantom Wallet Connect" would put a wallet's name beside xgames.fun's): each is left out, as a word,
213
+ // as two words that make one ("sign in", "x games") and, for RESERVED_INSIDE, inside a longer word ("walletconnect").
214
+ // What is left is the name ("Wallet Wars" → wars); nothing left is "game". A name that is taken gets a number (-2, -3).
215
+
216
+ export const SLUG_MIN = 3,
217
+ SLUG_MAX = 48;
218
+ /** Names xgames.fun routes by, never a game's link ("api-2" and the like are fine). */
219
+ export const RESERVED_SLUGS = new Set([
220
+ 'api',
221
+ 'play',
222
+ 'p',
223
+ 'v',
224
+ 'd',
225
+ 'signal',
226
+ 'world',
227
+ 'admin',
228
+ 'studio',
229
+ 'operator',
230
+ 'signin',
231
+ 'signout',
232
+ 'auth',
233
+ 'demos',
234
+ 'tokens',
235
+ 'meta',
236
+ 'launchpad',
237
+ 'dashboard',
238
+ 'dev',
239
+ 'gf',
240
+ 'www',
241
+ 'static',
242
+ 'assets',
243
+ 'new',
244
+ 'index',
245
+ 'null',
246
+ 'undefined',
247
+ ]);
248
+ /** Words a new link name never carries: signing in, wallets, support, and brands (xgames.fun's, wallets', sign-ins'). */
249
+ export const RESERVED_WORDS = new Set(
250
+ [
251
+ // signing in
252
+ 'login logon logout signin signon signup signout auth oauth sso password passwords passcode passkey 2fa mfa otp',
253
+ 'verify verified verification validate validation authenticate authentication account accounts recover recovery',
254
+ 'security kyc',
255
+ // wallets
256
+ 'wallet wallets connector walletconnect seedphrase mnemonic privatekey keypair airdrop airdrops claim claims',
257
+ 'presale whitelist revoke approve approval rectify dapp',
258
+ // support
259
+ 'support helpdesk customer billing refund refunds admin administrator moderator',
260
+ // brands
261
+ 'xgames xgamesfun gamesfun phantom metamask solflare trustwallet coinbase binance okx bybit kucoin ledgerlive',
262
+ 'trezor raydium magiceden opensea pumpfun solscan ethereum metaplex google gmail microsoft outlook icloud paypal',
263
+ 'stripe discord telegram twitter facebook instagram whatsapp tiktok youtube roblox epicgames playstation xbox',
264
+ 'nintendo netflix openai chatgpt anthropic',
265
+ ]
266
+ .join(' ')
267
+ .split(' '),
268
+ );
269
+ /** The reserved words also left out inside a longer word (none is part of an ordinary one). */
270
+ export const RESERVED_INSIDE = Object.freeze([
271
+ 'wallet',
272
+ 'login',
273
+ 'signup',
274
+ 'password',
275
+ 'passcode',
276
+ 'metamask',
277
+ 'solflare',
278
+ 'coinbase',
279
+ 'binance',
280
+ 'seedphrase',
281
+ 'privatekey',
282
+ 'airdrop',
283
+ 'xgames',
284
+ 'gamesfun',
285
+ 'magiceden',
286
+ 'opensea',
287
+ 'pumpfun',
288
+ 'paypal',
289
+ 'phantom',
290
+ ]);
291
+
292
+ const trimTo = (s, n) => s.slice(0, n).replace(/-+$/, '');
293
+
294
+ /**
295
+ * The link name xgames.fun gives a new game with this title (before a number for a taken name), and the reserved words
296
+ * it leaves out ("sign in" for two words that make one). → {slug, dropped: [word]}
297
+ */
298
+ export function linkName(title) {
299
+ const kebab = String(title ?? '')
300
+ .normalize('NFKD')
301
+ .replace(/[̀-ͯ]/g, '')
302
+ .toLowerCase()
303
+ .replace(/['’]/g, '')
304
+ .replace(/[^a-z0-9]+/g, '-')
305
+ .replace(/^-+|-+$/g, '');
306
+ const words = kebab.split('-').filter(Boolean),
307
+ kept = [],
308
+ dropped = [];
309
+ for (let i = 0; i < words.length; i++) {
310
+ if (i + 1 < words.length && RESERVED_WORDS.has(words[i] + words[i + 1])) {
311
+ dropped.push(`${words[i]} ${words[i + 1]}`);
312
+ i++;
313
+ continue;
314
+ }
315
+ if (RESERVED_WORDS.has(words[i]) || RESERVED_INSIDE.some(w => words[i].includes(w))) {
316
+ dropped.push(words[i]);
317
+ continue;
318
+ }
319
+ kept.push(words[i]);
320
+ }
321
+ let slug = trimTo(kept.join('-'), SLUG_MAX);
322
+ if (!slug) slug = 'game';
323
+ else if (slug.length < SLUG_MIN || RESERVED_SLUGS.has(slug)) slug = `${trimTo(slug, SLUG_MAX - 5)}-game`;
324
+ return { slug, dropped: [...new Set(dropped)] };
325
+ }
326
+
327
+ /** What the kit says before a first publish whose title has reserved words: the name it will get, and why. Or null. */
328
+ export function linkNameNote(title) {
329
+ const { slug, dropped } = linkName(title);
330
+ if (!dropped.length) return null;
331
+ return `Its link name will be "${slug}" (or "${slug}-2" and so on if that is taken), not one made of all of “${title}”: xgames.fun leaves sign-in, wallet, support and brand words out of every new link name, here ${dropped.join(', ')}. The title stays as it is, and a game keeps its link name for good: to choose another, change the title before its first publish.`;
332
+ }
@@ -21,7 +21,7 @@ export const SECRET_PATTERNS = ALL_PATTERNS.filter(([kind]) => kind !== 'JSON we
21
21
  global(re),
22
22
  ]);
23
23
  const JWT = global(ALL_PATTERNS.find(([kind]) => kind === 'JSON web token')[1]);
24
- /** Files refused by name, wherever they are in the folder. */
24
+ /** Files refused by name, wherever they are in the folder (by type: REFUSED_TYPES below, refused before the scan). */
25
25
  export const REFUSED_FILES = [
26
26
  [/(^|\/)\.env(\.[^/]*)?$/i, 'a .env file (secrets live there, never in a game)'],
27
27
  [/\.(pem|key|p12|pfx)$/i, 'a key or certificate file'],
@@ -30,6 +30,25 @@ export const REFUSED_FILES = [
30
30
  [/(^|\/)service[-_]?account[^/]*\.json$/i, 'a cloud service-account key'],
31
31
  ];
32
32
 
33
+ /**
34
+ * File types a published game never serves, refused by xgames.fun whatever the kit says: programs and installers for a
35
+ * computer or a phone, and archives, which a game's page does not need and a page that lures people hands out as a
36
+ * "download". By the last extension, any case (and .tar.gz). Not .gz or .br alone (a Unity WebGL build serves its
37
+ * files precompressed), and not .zip (web builds of some engines load one: a Ren'Py game.zip, a level pack).
38
+ */
39
+ export const REFUSED_TYPES = [
40
+ [
41
+ /\.(exe|msi|msix|msixbundle|appx|appxbundle|dll|scr|bat|cmd|vbs|vbe|wsf|wsh|hta|cpl|jar|apk|aab|xapk|ipa|dmg|pkg|mpkg|deb|rpm|appimage|sys|lnk|reg)$/i,
42
+ 'a program or an installer: a game never hands one out',
43
+ ],
44
+ [
45
+ /\.(rar|7z|tar|tar\.gz|tar\.bz2|tar\.xz|tgz|tbz|tbz2|txz|bz2|xz|cab|iso|lzh|arj)$/i,
46
+ 'an archive: publish the files themselves (a .zip the game loads is fine)',
47
+ ],
48
+ ];
49
+ /** Why xgames.fun refuses a file by its type (REFUSED_TYPES), or null. */
50
+ export const refusedType = path => REFUSED_TYPES.find(([re]) => re.test(path))?.[1] ?? null;
51
+
33
52
  const TEXT_EXT =
34
53
  /\.(m?js|cjs|json|jsonl|md|txt|html?|css|ya?ml|toml|ini|cfg|env|sh|ps1|py|ts|tsx|jsx|svg|csv|xml|example|map|glsl|wgsl|vert|frag)$/i;
35
54
  const BINARY_EXT =
@@ -212,6 +231,11 @@ export function backendOrigin(value, { own = [] } = {}) {
212
231
  if (!['https:', 'wss:'].includes(url.protocol)) throw bad('must start with https:// or wss://');
213
232
  if (url.username || url.password || url.search || url.hash || (url.pathname !== '/' && url.pathname !== ''))
214
233
  throw bad('must be an origin only (no path, query or user)');
234
+ // the host is written into the page's content-security policy, which takes a plain name only
235
+ if (!/^[a-z0-9.-]+$/.test(url.hostname))
236
+ throw bad(
237
+ `has a host the page's content-security policy cannot name: a host is letters, digits, dots and dashes only (no "_", ";", "," or quote; an international name in its xn-- form)`,
238
+ );
215
239
  if (value.trim().replace(/\/+$/, '').toLowerCase() !== url.origin) throw bad(`must be written as ${url.origin}`);
216
240
  const host = url.hostname.toLowerCase();
217
241
  if (IP_LITERAL.test(host) || !host.includes('.') || LOCAL_NAME.test(host))
@@ -9,7 +9,9 @@
9
9
  // the game's link and its last check), and the notes a folder keeps for its makers at its top
10
10
  // (README.md, NOTES.md, LAUNCH.md, FRICTION.md, CLAUDE.md, AGENTS.md, TODO.md: how the
11
11
  // game and its server work is no player's business).
12
- // Refused (nothing is sent): a .env file anywhere in the folder, and symbolic links (a tree holds ordinary files).
12
+ // Refused (nothing is sent): a .env file anywhere in the folder, symbolic links (a tree holds ordinary files), and the
13
+ // file types xgames.fun never serves (./scan.mjs REFUSED_TYPES: programs, installers,
14
+ // archives other than .zip).
13
15
  // Added, in the upload only: foundation.lock.json and adaptation-plan.json when the game has none (xgames.fun keeps
14
16
  // them with every version; a game made in the kit does not need them on disk). They are made from gf-three.json and
15
17
  // spec.json, and the lock is the same for every kit publish, so each update of a game keeps it.
@@ -22,6 +24,7 @@ import { existsSync } from 'node:fs';
22
24
  import { lstat, readdir, readFile } from 'node:fs/promises';
23
25
  import path from 'node:path';
24
26
  import { referenceNames, redactJson } from './names.mjs';
27
+ import { refusedType } from './scan.mjs';
25
28
  import { pageRoots, pageTitle } from './page.mjs';
26
29
  import { CMD } from '../command.mjs';
27
30
 
@@ -193,6 +196,11 @@ export async function buildTree(dir) {
193
196
  refused.push({ path: relPath, why: 'not an ordinary file' });
194
197
  continue;
195
198
  }
199
+ const type = refusedType(relPath);
200
+ if (type) {
201
+ refused.push({ path: relPath, why: type });
202
+ continue;
203
+ }
196
204
  let data = await readFile(full),
197
205
  own = false;
198
206
  // the spec goes up as a copy without the reference games' names (the file on disk keeps the person's words)