@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 CHANGED
@@ -31,6 +31,7 @@ xcore add multiplayer ./my-game # rooms players share (Godot's own mu
31
31
  xcore backend use custom ./my-game --url https://api.my-game.example # only if it calls a backend you run
32
32
  xcore loot add ./my-game # only if it gets an economy (a Solana economy on loot)
33
33
  xcore add vouchers ./my-game # its server pays results it checked from a grant, with its own key
34
+ xcore review ./my-game # the security review: what a cheater gets (--live once published)
34
35
  xcore login && xcore publish ./my-game
35
36
  xcore embed add https://mygame.com ./my-game # your own site may show it (an <iframe>)
36
37
  xcore domain add play.mygame.com ./my-game # Creator or Scale: it plays at your own domain
@@ -190,13 +191,35 @@ has behind it, so the whole game runs before it is published:
190
191
  | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
191
192
  | `http://127.0.0.1:8787/game/` | the published files (it runs `page.build` first, unless `--no-build`), with the play host's headers |
192
193
  | `/game/api/*` | the game's `server/` under workerd, the runtime xgames.fun runs it on: Durable Objects with their storage kept, WebSockets, `env.GF`, the game's database (a local D1 in `.gamesfun/dev/d1/`, its migrations applied), and its secrets from `.dev.vars`. A change under `server/` or to `.dev.vars` reloads it. Miniflare (with workerd, about 140 MB) is installed once, the first time a game with a server runs |
193
- | `/platform/v1/*` | sign-in, saves and leaderboards: `gamesfun.js` signs in a local player (each tab its own, so two tabs play a room together), and `env.GF.verifyPlayer` accepts its token. Saves and scores are kept in `.gamesfun/dev-platform.json`, never published |
194
+ | `/platform/v1/*` | sign-in, saves and leaderboards: `gamesfun.js` signs in a local player (each tab its own, so two tabs play a room together), and `env.GF.verifyPlayer` accepts its token. Saves and scores are kept in `.gamesfun/dev-platform.json`, never published, and held to the limits of the plan it imitates (below) |
195
+ | `/platform/v1/live` | the game's live settings (`gf.live`, below): its announcement, config and maintenance, from `.gamesfun/live.json` (read again on each request, never published). With maintenance on there, the game's pages show the holding page and its `api/` and `/platform/v1/*` answer `503` with code `maintenance`, as hosted |
194
196
  | `http://127.0.0.1:8788/` | the game's public project files (`env.GF.files`, `xcore files`), kept in `.gamesfun/dev/files/`: another origin, as `<slug>.xgamesusercontent.com` is to the published game |
195
197
  | `/loot/v1/rpc` | the economy's Solana devnet RPC: xgames.fun's shared relay (what a published page talks to unless the game brings its own: `xcore add rpc`), or your own with `XSOLANA_RPC_URL` in `.env` (its URL is never shown; a call the shared relay would refuse is said in the terminal). The economy's calls answer once it is live on devnet (`xcore loot approve`) |
196
198
 
197
199
  Games made with any engine get all of it through `gamesfun.js` (`xcore add signin`): on a plain local server it stands
198
- in with this browser's storage; under `xcore dev` it uses the real thing. What is not the same as hosted: no CPU or
199
- request limits, and the server's outbound `fetch` is not limited to `backend.json`'s origins.
200
+ in with this browser's storage; under `xcore dev` it uses the real thing.
201
+
202
+ **The plan's limits, refused as published.** `xcore dev` imitates one plan, Free unless `--plan creator` or `--plan
203
+ scale` (it says which when it starts), and its platform refuses what xgames.fun refuses on that plan, with the same
204
+ codes: the plan's boards (8 on Free) and board rules (16), entries a board (10,000), save bytes a game (10 MB), and
205
+ reads (1,200) and writes (600 saves, 600 scores) a minute, the server's and the pages' counted apart; project files'
206
+ total and the plan's file calls a minute (120). What a page may do is the same on every plan: it makes `high` boards
207
+ only, two a player and half the plan's boards in all (the game's server makes the rest, `low` ones too, and
208
+ `GF.scores.list` shows who made each); one player's saves from the page keep at most 1/128 of the game's save bytes
209
+ (80 KB on Free), and with the game's saves full a player still keeps 4 KB; one player writes 60 saves and 30 scores a
210
+ minute; a body is JSON (`content-type: application/json`) of at most 96 KB. A board read from the page shows each player
211
+ as an id of the game's own (16 letters and digits), never their account, with `me: true` on the caller's entry; without
212
+ a player token it reads the top 100 only (`offset` plus `limit` past 100 answers 401), may get the same answer again
213
+ for 5 seconds, and has its own minute (a tenth of the plan's reads). Ranks and offsets are counted to 10,000 (past it a
214
+ rank is `10001` with `rankAtLeast: true`). A public project file is an image, audio, video, a font, JSON or plain text
215
+ whose first bytes are that type (else private), and `env.GF.files.put` holds at most 1 MB of a string or ArrayBuffer
216
+ body (a bigger file goes as a stream with its `length`).
217
+
218
+ What is not imitated, and said instead: one player's requests to the game's server a minute and its CPU a request (the
219
+ dev log says when a request would pass them), and one address's reads of `/platform/v1` at the play edge (120 a minute;
220
+ here every tab is one address). Not imitated at all: the network's delays, the owner's moderation (bans, blocked
221
+ words), the board ids of the published game (these are this machine's own), and the server's outbound `fetch`, which is
222
+ not limited to `backend.json`'s origins. The counts a minute start again when `xcore dev` does.
200
223
 
201
224
  ## 3. Players, a database, a backend
202
225
 
@@ -212,17 +235,28 @@ engine takes:
212
235
  | A Godot 4 project | `addons/gamesfun/gamesfun.gd`, autoloaded as `GamesFun` | `await GamesFun.sign_in()`, `await GamesFun.saves_put("main", {})` |
213
236
  | A Unity project | `Assets/Plugins/GamesFun/` (`GamesFun.jslib`, `GamesFun.cs`) | `await GamesFun.SignIn()`, `await GamesFun.SavesPut("main", json)` |
214
237
 
215
- The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`), saves (any JSON, 64 KB
216
- each, 16 a player), leaderboards (submit, top, the player's own), the game's database (below) and its own server
238
+ The same calls everywhere: sign in and out, the player (`id`, `name`, `level`, `wallet`, `x`; `id` is the player's
239
+ account, `x:<X id>` or for a Google account `g:<id>`, an id different in every game, and never the wallet, which is
240
+ `wallet`, empty when the account has none), saves (any JSON, 64 KB each, 16 a player), leaderboards (submit, top, the player's own), the game's database (below) and its own server
217
241
  (`api`). Every call answers `{data, error, status}` and none throws, so a game plays on as a guest wherever xgames.fun is
218
242
  not there.
219
243
 
244
+ **Full screen.** Most players meet a game in a post on X or on a phone, where it shows in a small frame first; `xcore
245
+ adopt` warns when a game has no way to go full screen. A button that calls `GamesFun.fullscreen()` from the click
246
+ (`await GamesFun.fullscreen()` in Godot; `Screen.fullScreen` in Unity) does it wherever the game is: in xgames.fun's
247
+ play page the page around the game goes full screen (or fills the screen in an app's own browser, X's among them), on
248
+ its own link the browser does, and in a frame that allows none (a post on X on a computer) the game opens in a tab of
249
+ its own. It answers `{on, how: 'page' | 'browser' | 'tab' | 'none'}` (`none`: an iPhone's Safari, which has no full
250
+ screen for a page); pressed again it comes back, and `GamesFun.onFullscreen(on => …)` follows every change.
251
+
220
252
  **A leaderboard is the player's own word until the server takes it.** A signed-in player submits their own scores
221
253
  from the page, so anyone can submit any score for themselves from the browser's console. A game with its own server
222
254
  makes a board nobody can write that way: `env.GF.scores.setup('ranked', {writer: 'server'})` (or a pattern for boards
223
255
  to come, `'daily-*'`), then the page's submits
224
256
  to it are refused (their answer's `code` is `'server-only'`, HTTP 403) and the server submits each run it checked; the page reads the board as before
225
- ([docs/SERVER.md](docs/SERVER.md)).
257
+ ([docs/SERVER.md](docs/SERVER.md)). A board a page makes ranks higher first; a lower-first board (a time) is made by
258
+ the game's server, and a page's `order: 'low'` for a new board is refused (`code: 'order'`). `scores.top` gives each
259
+ entry's `player` as an id of this game's own and marks the signed-in player's own entry `me: true`.
226
260
 
227
261
  Where the sign-in comes from:
228
262
 
@@ -236,9 +270,15 @@ Where the sign-in comes from:
236
270
  can be made and tested before it is published. Under `xcore dev`, a local sign-in (one player a tab) and the
237
271
  platform answer as xgames.fun does, and the game's `server/` accepts its token.
238
272
 
239
- The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer`; a
240
- backend of your own checks it with any JWT library against `https://<slug>-play.xgames.fun/player/v1/key` (issuer
241
- `xgames.fun`, audience `game:<slug>`).
273
+ The token lasts an hour and stays in the page's memory. The game's `server/` checks it with `env.GF.verifyPlayer`,
274
+ first thing, before any work: a request without one answers 401 at once, since strangers' requests that do work count
275
+ toward what caps a game ([docs/SERVER.md](docs/SERVER.md), "Check the token first"). A backend of your own checks it
276
+ with any JWT library against `https://<slug>-play.xgames.fun/player/v1/key` (issuer `xgames.fun`, audience
277
+ `game:<slug>`). Its `sub` is the player's account (`x:<X id>`, or `g:<id>` for Google, different in every game);
278
+ `wallet` is a claim of its own, and may be absent: a game that keeps a player's data by wallet reads `wallet`.
279
+ xgames.fun replaces its signing key from time to time: a backend that keeps the key set must fetch it again when a
280
+ token names a key id (`kid`) it does not hold, once, not in a loop (most JWT libraries' remote key sets do).
281
+ `gamesfun.js` and the Godot and Unity clients check no token themselves.
242
282
 
243
283
  The page calls the game's own server with `GamesFun.api(path, init)` (Godot `GamesFun.api(path, init)`, Unity
244
284
  `GamesFun.Api(path, initJson)`): `path` is relative to the game's `api/` (`'finish'` is `api/finish`, at the
@@ -246,6 +286,59 @@ published origin and under a folder alike), `init` is `fetch`'s (an object `body
246
286
  player's token goes with it as `Authorization: Bearer …`, which the server checks with `env.GF.verifyPlayer` before
247
287
  it trusts who is asking. It answers `{data, error, status}` like every other call.
248
288
 
289
+ ### Live: an announcement, a remote config and maintenance, with no republish (`gf.live`)
290
+
291
+ On xgames.fun/console, a game's **Live** tab sets three things its players get within about 35 seconds, with no
292
+ republish (xgames.fun's play edge answers them itself from one cached record a game):
293
+
294
+ - **an announcement**: a short line of plain text (280 characters), `info` or `warning`, with an optional start and end;
295
+ - **a remote config**: a small JSON object (8 KB) the game reads at run time: a difficulty, an event on or off. The
296
+ console keeps its last 10 versions and reverts with a click;
297
+ - **maintenance**: the game paused with a message. Players who open it get a plain holding page from xgames.fun instead
298
+ of the game, its server's `api/*` (WebSockets too) and the page's saves and leaderboards answer `503` with code
299
+ `maintenance` (the server's code does not run), and turning it off brings the game back within about 35 seconds (up
300
+ to a minute). What is already running (an open WebSocket, an alarm) is not stopped.
301
+
302
+ `gamesfun.js` (1.5.0) reads them:
303
+
304
+ ```js
305
+ const { data: live } = await GamesFun.live.get(); // {announcement, config, version, maintenance}
306
+ const hard = live.config.difficulty === 'hard';
307
+ GamesFun.live.onChange(live => applyEvent(live.config.event)); // asked again every minute while the page is in view
308
+ await GamesFun.live.refresh(); // ask now
309
+ ```
310
+
311
+ `get()` answers what it knows if that is under a minute old, else asks; like every call it answers
312
+ `{data, error, status}` and never throws (unreachable: the last value, else nothing live). `announcement` is null
313
+ outside its start and end, `config` is `{}` when none is set, `maintenance` is null or `{message, since}`.
314
+
315
+ **The banner.** By default `gamesfun.js` shows the announcement in a small closable line at the top of the page (each
316
+ announcement until the player closes it), and a pause that starts while the game is open (any call answering
317
+ `maintenance` shows it at once). A game that shows them its own way turns it off with `data-live-banner="off"` on its
318
+ `<script src="gamesfun.js">`, `globalThis.GAMESFUN_LIVE_BANNER = false` before it loads, or
319
+ `GamesFun.live.banner(false)` before the page has loaded. Without the banner and without `onChange`, nothing is asked
320
+ until the game calls `get()`.
321
+
322
+ **On this machine** nothing is live on a plain local server; under `xcore dev`, `.gamesfun/live.json` stands in for the
323
+ console (read again on every request):
324
+
325
+ ```json
326
+ {
327
+ "announcement": {
328
+ "text": "Double XP all weekend!",
329
+ "level": "info",
330
+ "startsAt": null,
331
+ "endsAt": "2026-11-12T00:00:00Z"
332
+ },
333
+ "config": { "difficulty": "hard", "event": true },
334
+ "maintenance": null
335
+ }
336
+ ```
337
+
338
+ `"maintenance": {"message": "Back at 7pm"}` (or `true`) pauses the game as hosted. Times are ms or ISO text; what the
339
+ console would refuse (a config over 8 KB or not an object, an announcement over 280 characters) is said in the
340
+ terminal. Godot and Unity reach `gf.live` only through their JavaScript bridge for now.
341
+
249
342
  ### A database of its own: `xcore add database`
250
343
 
251
344
  `xcore add database <folder>` adds sign-in, and a database the game owns: **one D1 database** (SQLite) a game, made on
@@ -435,7 +528,7 @@ holds the addresses the economy went live at stays in the project (keep it in gi
435
528
  runs the tools from a terminal. Going live is the person's own step, approved in their own wallet: `xcore loot
436
529
  approve` in the folder with `xsolana.json`.
437
530
 
438
- **When loot is upgraded.** This package pins the tools for one build of loot (0.25.0). loot is upgraded in place on
531
+ **When loot is upgraded.** This package pins the tools for one build of loot (0.26.0, on devnet since 2026-11-10). loot is upgraded in place on
439
532
  devnet, and no version so far has carried accounts over: an economy that went live on an earlier build goes live
440
533
  again (`xcore loot approve`), and the next publish brings `gf/loot/` up to the installed package. **Nothing runs by
441
534
  itself** on chain: the page client sends what a player needs (settles, refunds, claims, a draw that is due); a
@@ -460,10 +553,36 @@ only to players signed in with X (`VOUCHERS.payOnly`). A grant names up to three
460
553
  pay the same grant while you move from one to the other.
461
554
  [docs/SERVER.md](docs/SERVER.md), "Paying a result", has both, side by side.
462
555
 
556
+ ### Review: `xcore review`
557
+
558
+ A game that pays is read the way a cheater reads it before players are paid. `xcore review <folder>` writes
559
+ `evidence/review-N/review.md` and `review.json`, prints what it found, and exits 1 on any high finding:
560
+
561
+ - **The economy**: `@xgamesfun/solana`'s own review (`xcore loot review`): each route that pays, what it trusts, its
562
+ ceiling, and the trade-offs in words. An installed version without it says so in one line, and the rest goes on.
563
+ - **The server**: `PAY.check` still the template (it pays nothing) or one that pays from the request alone (it never
564
+ reads `env`, so nothing the server keeps); `VOUCHERS.payOnly` against the grants (`'any'` with `perPerson`, whose
565
+ claims fail; `'any'` on a grant worth farming); a voucher key and the grants that name it; `trust` on every grant a
566
+ voucher pays; `.dev.vars` in git or in the published folder; a seed or key in the page's files; the test key in a
567
+ grant; every `api/` route the code names.
568
+ - **Live** (`--live`, after publishing; `--live <origin>` for another, such as `xcore dev`'s
569
+ `http://127.0.0.1:<port>/game/`): each of those routes asked with no sign-in and with a forged one, GET and POST (a
570
+ route that pays gets an empty body: nothing it sends can spend or sign); `api/vouchers/key` against the grants' keys;
571
+ the published economy's `live` against the folder's.
572
+
573
+ Each grant a voucher pays says how much its server checks before signing: `"trust": "decides"`, `"replays"` or
574
+ `"bounds"` (docs/SERVER.md, "How much your server can check"; the field needs the `@xgamesfun/solana` that has
575
+ `review`: 0.25.0 refuses it). `review.md` ends with the trade-offs for the person: for each paid route, its level,
576
+ what a cheater gets, and what the stronger level would cost. `xcore publish` says when a game that pays through
577
+ vouchers changed since its last review (it never refuses for it).
578
+
463
579
  ## 5. Publish
464
580
 
465
581
  `xcore login` signs this computer in (your browser signs in to xgames.fun and hands it a publish token for 30
466
- days; xcore never sees your password). `xcore publish <folder>` then builds the game, checks it (the page
582
+ days; xcore never sees your password). On the approval page you tick the games this computer may work on, or your
583
+ whole account, and `xcore login` says which it got (`login --status` says it again). A game you publish as new from
584
+ that sign-in is added to it; a command for any other game is refused with code `not-granted` ("your sign-in was not
585
+ given this game"): run `xcore login` again and tick it. `xcore publish <folder>` then builds the game, checks it (the page
467
586
  check on exactly the files built), and uploads its published folder with its `server/` bundle:
468
587
 
469
588
  1. **The files**: the published folder, without hidden files, `evidence/`, `node_modules/`, `server/`, `supabase/` and the makers' notes at its top (`README.md`, `NOTES.md`, `LAUNCH.md` and the like). A `.env` file or a link
@@ -537,8 +656,11 @@ free their slot unless their link is still live. What the game's server, saves a
537
656
  | Sites that embed it | 3 (`xcore embed`) | 10 | 25 |
538
657
  | Domains of your own | none | 1 (`xcore domain`) | 5 |
539
658
 
540
- Past what is included, a game is **capped** until the month ends: its server answers at most 60 requests a minute (20
541
- from one player) and saves and scores slow down, but nothing is lost and the game keeps working. Paid slots are $15 for
659
+ Past what is included, a game is **capped** until the month ends: **its server is switched off** (every `api/*` request
660
+ answers 451 with `reason: "capped"`, and a room that is running stops within about five minutes), and saves and scores
661
+ slow down (60 save writes, 30 score submits and 60 reads a minute). Its pages keep loading and nothing is lost: the server comes
662
+ back by itself when the cap lifts ([docs/SERVER.md](docs/SERVER.md), "Publishing it"). An unpublished or taken-down
663
+ game's server is switched off the same way. Paid slots are $15 for
542
664
  30 days, prepaid in SOL or USDC on xgames.fun (your console → Plans; no automatic renewal, 7 days' grace after the period
543
665
  ends; when they end, their games go back to free slots and nothing is taken down).
544
666
  Usage credit, bought the same way, lets Creator games go past what their slots include, at twice what xgames.fun pays,
@@ -579,7 +701,7 @@ A game made with xcore 0.1.0 that has `server/database.js` (`xcore add database`
579
701
  without one needs none: the D1 database, `xcore db` and `xcore files` are new.
580
702
 
581
703
  1. **Run `xcore add database` again.** It brings `server/database.js` up to date (your `RULES` and `LIMITS` are
582
- kept), with `gamesfun.js` (1.4.0) and the Godot or Unity glue, and lists each `dataOf(env)` call in `server/` that
704
+ kept), with `gamesfun.js` (1.6.0) and the Godot or Unity glue, and lists each `dataOf(env)` call in `server/` that
583
705
  needs an owner (step 3) as `file:line` with its change; `xcore publish` lists them again until each names its
584
706
  owner. On its first start the new `database.js` moves the documents to a table keyed by collection, owner and id,
585
707
  each under its owner. After that, do not roll the game back to a version with 0.1.0's `database.js`: its writes
@@ -620,9 +742,9 @@ without one needs none: the D1 database, `xcore db` and `xcore files` are new.
620
742
  WebAssembly). Such a server runs on a host of your own, declared with `backend use custom`; or its logic moves into
621
743
  `server/`, or into the page itself (WebAssembly is allowed: Pyodide runs Python in the browser). Each is a change to
622
744
  how the game is built: the person who made it decides.
623
- - **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (Node built-ins only with
624
- `nodejs_compat`, and no `fs`, `child_process` or listening sockets). A Node backend runs on a host of your own,
625
- declared with `backend use custom`.
745
+ - **A Node server as the backend on xgames.fun**: `server/` is a Worker, not Node (no Node built-ins: `nodejs_compat`
746
+ is not offered; no `fs`, `child_process`, raw sockets or listening sockets). A Node backend runs on a host of your
747
+ own, declared with `backend use custom`.
626
748
  - **Scripts, style sheets and fonts from a CDN**: the page loads code and styles from its own files only, so a copy goes
627
749
  in the game (or the package is bundled).
628
750
  - **A fallback to `index.html` for every path**: deep links of a client-side router reload to "not found"; use a hash
package/commands/dev.mjs CHANGED
@@ -4,21 +4,25 @@ import { guard, parse, printJson, UsageError } from '../lib/cli.mjs';
4
4
  import { resolveGame, shown } from '../lib/games.mjs';
5
5
  import { madeByKit } from '../lib/publish/index.mjs';
6
6
  import { CMD } from '../lib/command.mjs';
7
+ import { PLAN_NAMES } from '../lib/dev/plans.mjs';
7
8
 
8
- const usage = `Usage: xcore dev [dir] [--port <n>] [--no-build] [--open] [--json]
9
+ const usage = `Usage: xcore dev [dir] [--port <n>] [--plan <name>] [--no-build] [--open] [--json]
9
10
 
10
11
  Play the game on this machine as xgames.fun will host it, with everything a published game has behind it:
11
12
  http://127.0.0.1:<port>/game/ the published files, with the play host's headers
12
13
  /game/api/* the game's own server/ (Durable Objects, WebSockets, env.GF), reloaded on change
13
- /platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses
14
+ /platform/v1/* sign-in (a local player), saves and leaderboards, the same store env.GF uses,
15
+ with xgames.fun's limits on the plan it imitates (Free unless --plan)
14
16
  /loot/v1/rpc the economy's Solana devnet RPC (XSOLANA_RPC_URL in .env, else xgames.fun's relay)
15
17
  gamesfun.js (${CMD} add signin|database|multiplayer) uses all of it here, from any engine: open the page in two
16
18
  windows to play a room together. Running server/ needs Miniflare (workerd), installed once on first use (~140 MB).
17
19
  Saves, scores and Durable Object storage are kept in <dir>/.gamesfun/ (never published).
18
- --port <n> the port (default 8787)
19
- --no-build serve what is built already; do not run gamesfun.json page.build first
20
- --open open the page in your browser
21
- --json print where it runs as JSON, then keep running`;
20
+ --port <n> the port (default 8787)
21
+ --plan <name> the plan to imitate: free (the default), creator or scale: its boards, saves, reads and writes a
22
+ minute and file sizes, as xgames.fun holds a game on that plan (${CMD} plan shows a game's own)
23
+ --no-build serve what is built already; do not run gamesfun.json page.build first
24
+ --open open the page in your browser
25
+ --json print where it runs as JSON, then keep running`;
22
26
 
23
27
  export default {
24
28
  name: 'dev',
@@ -28,12 +32,20 @@ export default {
28
32
  guard(async () => {
29
33
  const { values, positionals } = parse(
30
34
  args,
31
- { port: { type: 'string' }, 'no-build': { type: 'boolean' }, open: { type: 'boolean' } },
35
+ {
36
+ port: { type: 'string' },
37
+ plan: { type: 'string' },
38
+ 'no-build': { type: 'boolean' },
39
+ open: { type: 'boolean' },
40
+ },
32
41
  usage,
33
42
  );
34
43
  const port = values.port === undefined ? 8787 : Number(values.port);
35
44
  if (!Number.isInteger(port) || port < 0 || port > 65535)
36
45
  throw new UsageError(`--port is a port number\n\n${usage}`);
46
+ const plan = String(values.plan ?? 'free').toLowerCase();
47
+ if (!PLAN_NAMES.includes(plan))
48
+ throw new UsageError(`--plan is ${PLAN_NAMES.slice(0, -1).join(', ')} or ${PLAN_NAMES.at(-1)}\n\n${usage}`);
37
49
  const dir = await resolveGame(positionals[0]);
38
50
  if (madeByKit(dir)) {
39
51
  console.error(
@@ -45,18 +57,27 @@ export default {
45
57
  const { startDev } = await import('../lib/dev/index.mjs');
46
58
  let dev;
47
59
  try {
48
- dev = await startDev(dir, { port, noBuild: values['no-build'] === true, log: say });
60
+ dev = await startDev(dir, { port, plan, noBuild: values['no-build'] === true, log: say });
49
61
  } catch (e) {
50
62
  if (e.code === 'EADDRINUSE')
51
63
  throw new Error(`port ${port} is in use: pick another with --port <n>`, { cause: e });
52
64
  throw e;
53
65
  }
54
66
  if (values.json)
55
- printJson({ url: dev.url, origin: dev.origin, files: dev.files, server: dev.server, rpc: dev.rpc });
67
+ printJson({
68
+ url: dev.url,
69
+ origin: dev.origin,
70
+ files: dev.files,
71
+ server: dev.server,
72
+ rpc: dev.rpc,
73
+ plan: dev.plan,
74
+ });
56
75
  else {
57
76
  say(`\n${shown(dir)} on this machine: ${dev.url}`);
58
77
  say(` server: ${dev.server ? (dev.server.running ? 'running' : 'not running (above)') : 'none (no server/)'}`);
59
- say(` sign-in, saves, scores: a local player; kept in ${shown(dev.store.file)}`);
78
+ say(
79
+ ` sign-in, saves, scores: a local player, with the ${dev.store.plan.name} plan's limits; kept in ${shown(dev.store.file)}`,
80
+ );
60
81
  say(` project files (env.GF.files, xcore files): public ones at ${dev.files}/, an origin of their own`);
61
82
  say(` economy RPC: ${dev.rpc}`);
62
83
  say('Ctrl+C stops it.');
@@ -1,17 +1,20 @@
1
1
  import { createInterface } from 'node:readline/promises';
2
2
  import { guard, parse, printJson } from '../lib/cli.mjs';
3
3
  import { openUrl } from '../lib/open.mjs';
4
- import { login, signInState, storeWords, studioOrigin } from '../lib/publish/client.mjs';
4
+ import { signInState, studioOrigin } from '../lib/publish/client.mjs';
5
+ import { currentReach, loginWithReach, loginWords, reachWords } from '../lib/login-reach.mjs';
5
6
  import { storeName } from '../lib/credentials.mjs';
6
7
  import { CMD } from '../lib/command.mjs';
7
8
 
8
9
  const usage = `Usage: xcore login [--code] [--status] [--json]
9
10
 
10
11
  Sign in to xgames.fun so you can publish your games (${CMD} publish). Your browser opens xgames.fun: sign in
11
- there and approve this computer. xcore never sees your password; it keeps a publish token for 30 days in
12
- ${storeName()} (never in a game or in this folder). ${CMD} logout ends it.
12
+ there, tick the games this computer may work on (or your whole account) and approve. xcore says which it got; a game
13
+ you publish as new from here is added to them, and for another, run ${CMD} login again and tick it. xcore never sees
14
+ your password; it keeps a publish token for 30 days in ${storeName()} (never in a game or in this folder).
15
+ ${CMD} logout ends it.
13
16
  --code no browser on this machine (SSH, a container): open the printed link anywhere and paste the code it shows
14
- --status who this computer is signed in as, and where the token is kept
17
+ --status who this computer is signed in as, which games it reaches, and where the token is kept
15
18
  GAMESFUN_STUDIO=<origin> signs in to another xgames.fun (a local Studio while developing).`;
16
19
 
17
20
  export default {
@@ -24,13 +27,24 @@ export default {
24
27
  const origin = studioOrigin();
25
28
  if (values.status) {
26
29
  const { token: _token, words, ...s } = await signInState(origin);
27
- if (values.json) printJson({ ...s, signedIn: s.state === 'signed-in' });
28
- else console.log(s.state === 'signed-in' ? words : `${words} Run: ${s.next}`);
30
+ const reach = s.state === 'signed-in' ? await currentReach(origin) : null;
31
+ if (values.json)
32
+ printJson({
33
+ ...s,
34
+ signedIn: s.state === 'signed-in',
35
+ ...(reach ? { scope: reach.scope, games: reach.games, reachChecked: reach.checked } : {}),
36
+ });
37
+ else
38
+ console.log(
39
+ s.state === 'signed-in'
40
+ ? `${words}${reachWords(reach) ? ` ${reachWords(reach)}` : ''}`
41
+ : `${words} Run: ${s.next}`,
42
+ );
29
43
  return 0;
30
44
  }
31
45
  const rl = values.code ? createInterface({ input: process.stdin, output: process.stderr }) : null;
32
46
  try {
33
- const r = await login({
47
+ const r = await loginWithReach({
34
48
  origin,
35
49
  code: values.code,
36
50
  open: url => openUrl(url, { web: true }),
@@ -38,10 +52,7 @@ export default {
38
52
  log: m => console.error(m),
39
53
  });
40
54
  if (values.json) printJson({ studio: origin, ...r });
41
- else
42
- console.log(
43
- `Signed in to ${origin} as ${r.email}. The token is kept in ${storeWords(r.store)} until ${new Date(r.expiresAt).toISOString().slice(0, 10)}. Next: ${CMD} publish`,
44
- );
55
+ else console.log(loginWords(r));
45
56
  return 0;
46
57
  } finally {
47
58
  rl?.close();
@@ -1,5 +1,6 @@
1
1
  import { guard, parse, printJson } from '../lib/cli.mjs';
2
2
  import { logout, studioOrigin } from '../lib/publish/client.mjs';
3
+ import { forgetReach } from '../lib/login-reach.mjs';
3
4
 
4
5
  const usage = `Usage: xcore logout [--json]
5
6
 
@@ -26,6 +27,7 @@ export default {
26
27
  const { values } = parse(args, {}, usage);
27
28
  const origin = studioOrigin();
28
29
  const r = await logout(origin);
30
+ await forgetReach(origin);
29
31
  if (values.json) printJson({ studio: origin, ...r });
30
32
  else
31
33
  console.log(
package/commands/loot.mjs CHANGED
@@ -9,10 +9,11 @@ import { existsSync } from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import { guard, parse, printJson } from '../lib/cli.mjs';
11
11
  import { resolveGame, shown } from '../lib/games.mjs';
12
- import { ECONOMY_FILE, solanaCommand } from '../lib/economy.mjs';
12
+ import { ECONOMY_FILE, solanaCommand, solanaHas } from '../lib/economy.mjs';
13
13
  import { addLoot } from '../lib/publish/adopt.mjs';
14
14
  import { solanaRpc } from '../lib/solana-rpc.mjs';
15
15
  import { CMD } from '../lib/command.mjs';
16
+ import { NO_REVIEW } from '../lib/review.mjs';
16
17
 
17
18
  const usage = `Usage: xcore loot <command> [options]
18
19
 
@@ -21,6 +22,8 @@ A game's economy on loot, one shared Solana program (devnet only until it is aud
21
22
  xcore loot add [game] where the game's economy (${ECONOMY_FILE}) and its page client (gf/loot/) go, and the
22
23
  copy once the economy is there (a brought game: the folder its build copies as it is)
23
24
  xcore loot check|simulate|cost|explain [file] refusals and warnings; where the money goes; what it costs; in words
25
+ xcore loot review [file] what a cheater gets from each route that pays, and the trade-offs in words (xcore
26
+ review adds the game's server and, with --live, the published game)
24
27
  xcore loot approve [file] in a game's folder: put its economy live, approved in your own wallet
25
28
  xcore loot help everything else xsolana does (its help says \`xsolana <command>\`: run each as
26
29
  \`xcore loot <command>\`)
@@ -35,7 +38,7 @@ copy: never edit it; publish brings it up to date). A brought game with a build
35
38
  copies as it is (gamesfun.json page.static), so every build carries them. It prints the next steps.`;
36
39
 
37
40
  /** xsolana's commands that read an economy file ([file], default xsolana.json). */
38
- const ON_FILE = new Set(['check', 'simulate', 'cost', 'explain', 'publish', 'status', 'approve']);
41
+ const ON_FILE = new Set(['check', 'simulate', 'cost', 'explain', 'review', 'publish', 'status', 'approve']);
39
42
 
40
43
  export default {
41
44
  name: 'loot',
@@ -70,6 +73,11 @@ export default {
70
73
  );
71
74
  return 1;
72
75
  }
76
+ // an installed package from before `review`: what to do, instead of its help
77
+ if (args[0] === 'review' && !solanaHas('review')) {
78
+ console.error(`xcore: ${NO_REVIEW}.`);
79
+ return 1;
80
+ }
73
81
  // a game made before the file was named xsolana.json keeps loot.json: given to the command when it names no file
74
82
  const named = args.slice(1).some(a => a.endsWith('.json'));
75
83
  let rest =
@@ -99,9 +107,10 @@ export default {
99
107
  };
100
108
 
101
109
  /** xsolana's commands a person runs as `xcore loot <command>`. */
102
- const COMMANDS = 'guide|examples|check|simulate|cost|explain|publish|approve|status|connect|mcp|tool|version|help';
110
+ const COMMANDS =
111
+ 'guide|examples|check|simulate|cost|explain|review|publish|approve|status|connect|mcp|tool|version|help';
103
112
  /** Its tools an agent calls (xsolana_check …) that are also a command here. */
104
- const TOOLS = 'guide|examples|check|simulate|cost|explain|publish|status';
113
+ const TOOLS = 'guide|examples|check|simulate|cost|explain|review|publish|status';
105
114
 
106
115
  /**
107
116
  * xsolana's text as xcore's: its pinned npx forms and its `xsolana <command>` become `xcore loot <command>`, and the
@@ -8,6 +8,7 @@ import { studioOrigin } from '../lib/publish/client.mjs';
8
8
  import { gate } from '../lib/publish/gate.mjs';
9
9
  import { buildFailure, pageRoots, runBuild } from '../lib/publish/page.mjs';
10
10
  import { CMD } from '../lib/command.mjs';
11
+ import { reviewNudge } from '../lib/review.mjs';
11
12
 
12
13
  const usage = `Usage: xcore publish [dir] [--draft] [--new] [--title <name>] [--status] [--check [--verify [--port <n>]]] [--json]
13
14
 
@@ -68,6 +69,9 @@ export function publishCommand({
68
69
  );
69
70
  const dir = await resolveGame(positionals[0]);
70
71
  const origin = studioOrigin();
72
+ // a game that pays results through vouchers, changed since its last security review: one line, never a refusal
73
+ const nudge = values.status || values.json ? null : reviewNudge(dir);
74
+ if (nudge) console.error(nudge);
71
75
  if (values.status) {
72
76
  const s = await publishStatus(dir, { origin });
73
77
  if (values.json) printJson(s);
@@ -0,0 +1,106 @@
1
+ // `xcore review [game] [--live [origin]]`: the security review of a brought game (../lib/review.mjs): its economy by
2
+ // @xgamesfun/solana's own review, its server and page by this package's reading, and with --live the published game
3
+ // asked as a stranger. The record goes in <game>/evidence/review-N/; any high finding exits 1.
4
+ import { guard, parse, printJson, UsageError } from '../lib/cli.mjs';
5
+ import { resolveGame, shown } from '../lib/games.mjs';
6
+ import { readLinks } from '../lib/publish/index.mjs';
7
+ import { studioOrigin } from '../lib/publish/client.mjs';
8
+ import { CMD } from '../lib/command.mjs';
9
+
10
+ const usage = `Usage: xcore review [game] [--live [origin]] [--out <dir>] [--json]
11
+
12
+ The security review of a game, read as a cheater would: what a script gets without playing, and what to fix first.
13
+ the economy @xgamesfun/solana's own review of the economy file (each route that pays: what it trusts, its ceiling,
14
+ its findings, the trade-offs in words), when the installed one has it
15
+ the server PAY.check written or still the template, or paying from the request alone; VOUCHERS.payOnly against
16
+ the grants; the voucher key and the grants that name it; "trust" on every grant a voucher pays;
17
+ .dev.vars in git or in the published folder; a seed or key in the page's files; every api/ route
18
+ --live the published game asked as a stranger: each api/ route with no sign-in and with a forged one (GET
19
+ and POST; a route that pays gets an empty body, so nothing it sends can spend or sign);
20
+ api/vouchers/key against the grants' keys; the published economy's "live" against this folder's.
21
+ The origin defaults to the game's link from its last publish; under ${CMD} dev, give its address
22
+ (--live http://127.0.0.1:<port>/game/).
23
+ --out <dir> where the record goes (default: <game>/evidence/review-N/: review.md and review.json)
24
+ --json the record as JSON
25
+ review.md ends with the trade-offs for the person: for each route that pays, how the server checks it (decides,
26
+ replays or bounds), what a cheater gets, and what the stronger level would cost. Any high finding exits 1.
27
+ docs/SERVER.md, "How much your server can check", is the guide.`;
28
+
29
+ /** --live takes an origin or nothing: `--live`, `--live <http…>`, `--live=<http…>`. → {args without it, live} */
30
+ export function liveFlag(args) {
31
+ const rest = [];
32
+ let live = null;
33
+ for (let i = 0; i < args.length; i++) {
34
+ const a = args[i];
35
+ if (a === '--live') {
36
+ live = true;
37
+ if (/^https?:\/\//i.test(args[i + 1] ?? '')) live = args[++i];
38
+ } else if (a.startsWith('--live=')) live = a.slice('--live='.length) || true;
39
+ else rest.push(a);
40
+ }
41
+ return { rest, live };
42
+ }
43
+
44
+ export default {
45
+ name: 'review',
46
+ summary: 'the security review: what a cheater gets from its economy and server, with --live the published game',
47
+ usage,
48
+ run: args =>
49
+ guard(async () => {
50
+ const { rest, live } = liveFlag(args);
51
+ const { values, positionals } = parse(rest, { out: { type: 'string' } }, usage);
52
+ const dir = await resolveGame(positionals[0]);
53
+ let base = null,
54
+ liveWhy = `not asked for (${CMD} review --live)`;
55
+ if (typeof live === 'string') {
56
+ try {
57
+ base = new URL(live).href;
58
+ } catch {
59
+ throw new UsageError(`--live takes an address (https://… or http://127.0.0.1:<port>/game/)\n\n${usage}`);
60
+ }
61
+ } else if (live) {
62
+ const known = (await readLinks(dir))[studioOrigin()];
63
+ base = known?.gameUrl ?? null;
64
+ if (!base) {
65
+ liveWhy = `${shown(dir)} has no published link here: publish it, or run ${CMD} dev and give its address (--live http://127.0.0.1:<port>/game/)`;
66
+ if (!values.json) console.error(`Not asked live: ${liveWhy}.`);
67
+ }
68
+ }
69
+ const { reviewGame } = await import('../lib/review.mjs');
70
+ if (base && !values.json) console.error(`Asking ${base} as a stranger (no sign-in, and a forged one)…`);
71
+ const r = await reviewGame(dir, { live: base, liveWhy, out: values.out ?? null });
72
+ if (values.json) printJson(r);
73
+ else console.log(summary(r, shown(dir)));
74
+ return r.ok ? 0 : 1;
75
+ }, args),
76
+ };
77
+
78
+ /** What the command prints: the verdict, each finding, and where the record and the trade-offs are. */
79
+ function summary(r, where) {
80
+ const c = r.counts;
81
+ const lines = [
82
+ `review: ${r.ok ? 'PASS' : 'FAIL'} ${c.high} high, ${c.medium} medium, ${c.low} low, ${c.info} info · evidence ${shown(r.outDir)}`,
83
+ ];
84
+ for (const f of r.findings) {
85
+ lines.push(` ${f.severity.padEnd(6)} ${f.id}${f.route ? ` (${f.route})` : ''}: ${f.message}`);
86
+ if (f.fix && f.severity !== 'info') lines.push(` fix: ${f.fix}`);
87
+ }
88
+ lines.push(
89
+ r.economy.file
90
+ ? r.economy.available
91
+ ? `economy: ${r.economy.file}, ${r.economy.routes.length} routes reviewed by @xgamesfun/solana ${r.economy.version}`
92
+ : `economy: ${r.economy.file} not reviewed: ${r.economy.why}`
93
+ : `economy: ${where} has none`,
94
+ `server: ${r.server.routes.length} api/ routes named${r.server.facts.vouchers ? `; PAY.check ${r.server.facts.pay === 'template' ? 'is still the template' : r.server.facts.pay}, payOnly ${r.server.facts.payOnly ?? '?'}` : ''}`,
95
+ r.live ? `live: ${r.live.base}, ${r.live.probes.length} requests` : `live: ${r.liveWhy}`,
96
+ );
97
+ for (const t of r.tradeoffs)
98
+ lines.push(
99
+ `paid route ${t.route}: ${t.trust ? `${t.trust}${t.declared ? '' : ' (read so by the economy review; not declared)'}` : 'trust not declared'}`,
100
+ );
101
+ if (r.tradeoffs.length)
102
+ lines.push(
103
+ `For the person: ${shown(r.outDir)}/review.md ends with each paid route's level, what a cheater gets and what the stronger level costs. Tell them, and let them choose.`,
104
+ );
105
+ return lines.join('\n');
106
+ }