@xgamesfun/xcore 0.1.0 → 0.2.1

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.
Files changed (72) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +183 -84
  3. package/bin/xcore.mjs +13 -8
  4. package/commands/add.mjs +11 -8
  5. package/commands/adopt.mjs +9 -7
  6. package/commands/backend.mjs +2 -2
  7. package/commands/check.mjs +4 -4
  8. package/commands/db.mjs +218 -0
  9. package/commands/dev.mjs +8 -6
  10. package/commands/domain.mjs +8 -8
  11. package/commands/embed.mjs +3 -3
  12. package/commands/files.mjs +106 -0
  13. package/commands/login.mjs +9 -17
  14. package/commands/logout.mjs +15 -4
  15. package/commands/loot.mjs +2 -2
  16. package/commands/plan.mjs +22 -19
  17. package/commands/publish.mjs +54 -21
  18. package/commands/secret.mjs +6 -6
  19. package/docs/SERVER.md +218 -18
  20. package/lib/add.mjs +265 -40
  21. package/lib/browser.mjs +11 -2
  22. package/lib/check.mjs +10 -9
  23. package/lib/cli.mjs +24 -9
  24. package/lib/client/gamesfun.js +242 -122
  25. package/lib/client/godot/gamesfun.gd +17 -11
  26. package/lib/client/godot/gamesfun_peer.gd +2 -2
  27. package/lib/client/server/0001_init.sql +10 -0
  28. package/lib/client/server/database.js +271 -109
  29. package/lib/client/server/gf-db.js +55 -0
  30. package/lib/client/server/gf-docs.js +56 -0
  31. package/lib/client/server/rooms.js +16 -9
  32. package/lib/client/server/rpc.js +7 -7
  33. package/lib/client/server/stock.js +37 -0
  34. package/lib/client/server/vouchers.js +127 -46
  35. package/lib/client/unity/GamesFun.cs +15 -12
  36. package/lib/client/unity/GamesFun.jslib +1 -1
  37. package/lib/config-dir.mjs +1 -1
  38. package/lib/credentials.mjs +10 -5
  39. package/lib/db.mjs +355 -0
  40. package/lib/dev/d1.mjs +486 -0
  41. package/lib/dev/files.mjs +247 -0
  42. package/lib/dev/gf-local.js +69 -3
  43. package/lib/dev/index.mjs +68 -20
  44. package/lib/dev/limits.mjs +2 -2
  45. package/lib/dev/platform.mjs +111 -12
  46. package/lib/dev/server.mjs +54 -5
  47. package/lib/dev/tools.mjs +71 -18
  48. package/lib/files.mjs +208 -0
  49. package/lib/open.mjs +1 -1
  50. package/lib/play-policy.mjs +14 -5
  51. package/lib/publish/adopt.mjs +73 -33
  52. package/lib/publish/client.mjs +130 -20
  53. package/lib/publish/gate.mjs +4 -2
  54. package/lib/publish/hosting.mjs +5 -5
  55. package/lib/publish/index.mjs +162 -40
  56. package/lib/publish/oidc.mjs +26 -5
  57. package/lib/publish/page.mjs +1 -1
  58. package/lib/publish/plans.mjs +19 -12
  59. package/lib/publish/scan.mjs +78 -102
  60. package/lib/publish/secrets.mjs +4 -5
  61. package/lib/publish/site.mjs +9 -10
  62. package/lib/publish/tree.mjs +8 -8
  63. package/lib/secret-patterns.mjs +53 -16
  64. package/lib/secret-store.mjs +2 -2
  65. package/lib/server/bundle.mjs +113 -13
  66. package/lib/server/contract.mjs +440 -16
  67. package/lib/server/data-calls.mjs +136 -0
  68. package/lib/server/secrets.mjs +9 -7
  69. package/lib/solana-rpc.mjs +5 -5
  70. package/lib/state-folder.mjs +23 -0
  71. package/package.json +4 -4
  72. package/skills/bring-your-own-game/SKILL.md +10 -9
@@ -1,6 +1,6 @@
1
- // Talking to games.fun from the kit: signing in, and the publish calls (games.fun's kit routes).
1
+ // Talking to xgames.fun from the kit: signing in, and the publish calls (xgames.fun's kit routes).
2
2
  //
3
- // Sign in (`xcore login`) never sees a password: the person's own browser signs in to games.fun and approves this
3
+ // Sign in (`xcore login`) never sees a password: the person's own browser signs in to xgames.fun and approves this
4
4
  // computer; the kit receives a one-time code on a loopback address only it listens on (127.0.0.1, a free port, for
5
5
  // five minutes) and trades it, with a secret it never sent anywhere (PKCE), for a publish token. `--code` is the same
6
6
  // without the loopback: the page shows the code and the person pastes it here. The token is kept by lib/credentials.mjs
@@ -8,9 +8,10 @@
8
8
  import { createServer } from 'node:http';
9
9
  import { createHash, randomBytes } from 'node:crypto';
10
10
  import os from 'node:os';
11
- import { deleteSecret, loadSecret, saveSecret } from '../credentials.mjs';
11
+ import { deleteSecret, listEntries, loadSecret, saveSecret } from '../credentials.mjs';
12
+ import { CMD } from '../command.mjs';
12
13
 
13
- /** games.fun's Studio. GAMESFUN_STUDIO points the kit at another one (a local Studio while developing). */
14
+ /** xgames.fun's Studio. GAMESFUN_STUDIO points the kit at another one (a local Studio while developing). */
14
15
  export const DEFAULT_STUDIO = 'https://xgames.fun'; // canonical Studio origin; old aliases remain available for existing clients
15
16
  export function studioOrigin(env = process.env) {
16
17
  const raw = String(env.GAMESFUN_STUDIO ?? '').trim() || DEFAULT_STUDIO;
@@ -25,9 +26,11 @@ export function studioOrigin(env = process.env) {
25
26
  throw new Error(`GAMESFUN_STUDIO must be an https address (or http on this machine): ${raw}`);
26
27
  return u.origin;
27
28
  }
29
+ // The key the sign-in is kept under. It is an identifier, not the platform's name: 0.1.0 and the kit save under "games.fun <origin>",
30
+ // so it stays that, or everyone would be signed out by an upgrade and their old token could not be revoked.
28
31
  export const credentialKey = origin => `games.fun ${origin}`;
29
32
  /**
30
- * games.fun's own origins for a Studio origin, as its publish route counts them (playOrigin and studioOriginFor): the
33
+ * xgames.fun's own origins for a Studio origin, as its publish route counts them (playOrigin and studioOriginFor): the
31
34
  * Studio and its play host, play.<studio host> (play.localhost:<port> for a Studio on this machine). A backend.json
32
35
  * that lists one of them is refused there; the game reaches its own host without listing it.
33
36
  */
@@ -61,7 +64,7 @@ export async function login({
61
64
  env = process.env,
62
65
  fetchImpl = fetch,
63
66
  } = {}) {
64
- // A games.fun without the kit's routes (not deployed yet) answers 404 here: say so now, not after a 5-minute wait.
67
+ // A xgames.fun without the kit's routes (not deployed yet) answers 404 here: say so now, not after a 5-minute wait.
65
68
  await call(origin, '/api/kit/me', { fetchImpl }).catch(e => {
66
69
  if (e.status !== 401) throw e;
67
70
  });
@@ -71,7 +74,7 @@ export async function login({
71
74
  let code;
72
75
  if (byCode) {
73
76
  const url = `${origin}/kit/connect?${params}`;
74
- log(`Open this page, sign in to games.fun and approve this computer:\n ${url}`);
77
+ log(`Open this page, sign in to xgames.fun and approve this computer:\n ${url}`);
75
78
  await open?.(url);
76
79
  code = String(await prompt('Paste the code the page shows: ')).trim();
77
80
  } else {
@@ -116,7 +119,7 @@ export async function login({
116
119
  server.listen(0, '127.0.0.1', async () => {
117
120
  params.set('port', String(server.address().port));
118
121
  const url = `${origin}/kit/connect?${params}`;
119
- log(`Opening games.fun in your browser to sign in. If it does not open, visit:\n ${url}`);
122
+ log(`Opening xgames.fun in your browser to sign in. If it does not open, visit:\n ${url}`);
120
123
  try {
121
124
  await open?.(url);
122
125
  } catch {}
@@ -145,23 +148,128 @@ export async function savedLogin(origin = studioOrigin(), { env = process.env }
145
148
  return s ? { token: s.secret, email: s.meta.email, expiresAt: s.meta.expiresAt, store: s.store } : null;
146
149
  }
147
150
 
148
- /** Log out: revoke the token at games.fun (when it answers) and forget it here. → { revoked, forgot } */
151
+ /** The words for where a token is kept (`store` as saveSecret names it). */
152
+ export const storeWords = store =>
153
+ store === 'file'
154
+ ? 'a file only you can read'
155
+ : store === 'dpapi'
156
+ ? 'a file encrypted for your Windows user'
157
+ : store === 'keychain'
158
+ ? 'the macOS keychain'
159
+ : store === 'secret-service'
160
+ ? 'the Secret Service'
161
+ : (store ?? 'the secret store');
162
+
163
+ /**
164
+ * Where this computer's sign-in to `origin` stands, in one word, with what to run next:
165
+ * signed-out --login--> signed-in --(30 days)--> expired --login--> signed-in; signed-in --logout--> signed-out
166
+ * unreadable: the record says a token is kept in a store this session cannot read (the Secret Service over SSH,
167
+ * a locked keyring); signing in again here fixes it.
168
+ * → { state, studio, email?, expiresAt?, store?, token? (signed-in only), words, next }
169
+ */
170
+ export async function signInState(origin = studioOrigin(), { env = process.env, now = Date.now() } = {}) {
171
+ const entry = (await listEntries({ env }))[credentialKey(origin)];
172
+ if (!entry)
173
+ return { state: 'signed-out', studio: origin, words: `Not signed in to ${origin}.`, next: `${CMD} login` };
174
+ const facts = { studio: origin, email: entry.email ?? null, expiresAt: entry.expiresAt ?? null, store: entry.store };
175
+ const s = await savedLogin(origin, { env });
176
+ if (!s)
177
+ return {
178
+ state: 'unreadable',
179
+ ...facts,
180
+ words: `Signed in to ${origin} as ${facts.email}, but its token is kept in ${storeWords(entry.store)}, which this session cannot read (over SSH the Secret Service is not reachable). Sign in again here, or use the session that signed in.`,
181
+ next: `${CMD} login`,
182
+ };
183
+ if (s.expiresAt && Date.parse(s.expiresAt) <= now)
184
+ return {
185
+ state: 'expired',
186
+ ...facts,
187
+ words: `The sign-in to ${origin} as ${facts.email} ended on ${s.expiresAt.slice(0, 10)}. Sign in again.`,
188
+ next: `${CMD} login`,
189
+ };
190
+ return {
191
+ state: 'signed-in',
192
+ ...facts,
193
+ token: s.token,
194
+ words: `Signed in to ${origin} as ${facts.email} until ${facts.expiresAt} (token in ${storeWords(entry.store)}).`,
195
+ next: `${CMD} publish`,
196
+ };
197
+ }
198
+
199
+ /**
200
+ * Log out: revoke the token at xgames.fun (when it answers) and forget it here. → { revoked, forgot, why? }
201
+ * `why`, when something was forgotten but not revoked: 'unreadable' (this session cannot read the token, so it was
202
+ * never sent), 'ended' (xgames.fun says it had already expired or been revoked), 'offline' (xgames.fun did not answer)
203
+ * or 'refused' (xgames.fun answered with an error).
204
+ */
149
205
  export async function logout(origin = studioOrigin(), { env = process.env, fetchImpl = fetch } = {}) {
150
206
  const s = await savedLogin(origin, { env });
151
- let revoked = false;
207
+ let revoked = false,
208
+ why = 'unreadable';
152
209
  if (s) {
153
210
  try {
154
211
  await call(origin, '/api/kit/token', { method: 'DELETE', token: s.token, fetchImpl });
155
212
  revoked = true;
156
- } catch {
157
- /* offline or already revoked */
213
+ } catch (e) {
214
+ why = e.status === 401 || e.status === 403 ? 'ended' : e.status === 0 || e.status >= 500 ? 'offline' : 'refused';
158
215
  }
159
216
  }
160
217
  const forgot = await deleteSecret(credentialKey(origin), { env });
161
- return { revoked, forgot };
218
+ return forgot && !revoked ? { revoked, forgot, why } : { revoked, forgot };
219
+ }
220
+
221
+ /**
222
+ * An answer Studio sent as {"$download": url}: its JSON over 4 MB (Vercel's 4.5 MB answer limit), written to its
223
+ * bucket and signed for a while, which a call fetches and reads as the answer. Nothing of the sign-in goes with it.
224
+ */
225
+ const downloadOf = v =>
226
+ v && typeof v === 'object' && !Array.isArray(v) && Object.keys(v).length === 1 && typeof v.$download === 'string'
227
+ ? v.$download
228
+ : null;
229
+ async function followDownload(url, fetchImpl) {
230
+ let u = null;
231
+ try {
232
+ u = new URL(url);
233
+ } catch {
234
+ /* not an address */
235
+ }
236
+ if (
237
+ !u ||
238
+ !(u.protocol === 'https:' || (u.protocol === 'http:' && /^(localhost|127\.0\.0\.1|\[::1\])$/.test(u.hostname)))
239
+ )
240
+ throw Object.assign(
241
+ new Error("xgames.fun's answer was too big to send directly, and its download address is not one xcore follows"),
242
+ { status: 0 },
243
+ );
244
+ let res;
245
+ try {
246
+ res = await fetchImpl(u.href);
247
+ } catch (e) {
248
+ throw Object.assign(
249
+ new Error(
250
+ `xgames.fun's answer was too big to send directly, and its download did not come (${e.cause?.code ?? e.message}): try again`,
251
+ ),
252
+ { status: 0 },
253
+ );
254
+ }
255
+ if (!res.ok)
256
+ throw Object.assign(
257
+ new Error(`xgames.fun's answer was too big to send directly, and its download failed (${res.status}): try again`),
258
+ { status: res.status },
259
+ );
260
+ try {
261
+ return JSON.parse(await res.text());
262
+ } catch {
263
+ throw Object.assign(new Error("xgames.fun's answer was too big to send directly, and its download is not JSON"), {
264
+ status: 0,
265
+ });
266
+ }
162
267
  }
163
268
 
164
- /** One JSON call to games.fun (`headers`: more, such as the GitHub OIDC credential's). Throws an Error with games.fun's own words (and .status). */
269
+ /**
270
+ * One JSON call to xgames.fun (`headers`: more, such as the GitHub OIDC credential's). An answer over 4 MB that Studio
271
+ * sent as {"$download": url} is fetched from there. Throws an Error with xgames.fun's own words (and .status).
272
+ */
165
273
  export async function call(origin, route, { method = 'GET', body, token, headers = {}, fetchImpl = fetch } = {}) {
166
274
  let res;
167
275
  try {
@@ -175,7 +283,7 @@ export async function call(origin, route, { method = 'GET', body, token, headers
175
283
  body: body ? JSON.stringify(body) : undefined,
176
284
  });
177
285
  } catch (e) {
178
- throw Object.assign(new Error(`Could not reach games.fun at ${origin} (${e.cause?.code ?? e.message}).`), {
286
+ throw Object.assign(new Error(`Could not reach xgames.fun at ${origin} (${e.cause?.code ?? e.message}).`), {
179
287
  status: 0,
180
288
  });
181
289
  }
@@ -190,15 +298,17 @@ export async function call(origin, route, { method = 'GET', body, token, headers
190
298
  const words =
191
299
  value?.error ??
192
300
  (res.status === 404
193
- ? `games.fun at ${origin} does not offer publishing from the command line yet.`
194
- : `games.fun answered ${res.status}.`);
195
- // `code`: games.fun's word for why ('plan', 'taken'…); `answered`: false when the route itself is not there (a
196
- // games.fun older than the command)
301
+ ? `xgames.fun at ${origin} does not offer publishing from the command line yet.`
302
+ : `xgames.fun answered ${res.status}.`);
303
+ // `code`: xgames.fun's word for why ('plan', 'taken'…); `answered`: false when the route itself is not there (a
304
+ // xgames.fun older than the command); `answer`: the rest of what it said (a refused migration's name and why)
197
305
  throw Object.assign(new Error(words), {
198
306
  status: res.status,
199
307
  answered: value != null,
200
308
  ...(value?.code ? { code: value.code } : {}),
309
+ ...(value && typeof value === 'object' ? { answer: value } : {}),
201
310
  });
202
311
  }
203
- return value;
312
+ const download = downloadOf(value);
313
+ return download ? followDownload(download, fetchImpl) : value;
204
314
  }
@@ -2,11 +2,12 @@
2
2
  // caller's: this package's page check for a game made anywhere (../check.mjs), or the kit's six gates for a game the
3
3
  // kit made (the kit passes its own `check`). A pass is remembered in <game>/.gamesfun/gate.json with the files'
4
4
  // fingerprint (./tree.mjs), so publishing again without a change does not check again; any change to a published file
5
- // checks again. A game whose checks fail can still go up as a private draft (a version in the person's games.fun
5
+ // checks again. A game whose checks fail can still go up as a private draft (a version in the person's xgames.fun
6
6
  // Studio, with no public link), and the person is told which checks failed.
7
7
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
8
8
  import path from 'node:path';
9
9
  import { buildTree } from './tree.mjs';
10
+ import { stateFolder } from '../state-folder.mjs';
10
11
 
11
12
  export const gateFile = dir => path.join(dir, '.gamesfun', 'gate.json');
12
13
 
@@ -18,7 +19,7 @@ export async function readGate(dir) {
18
19
  }
19
20
  }
20
21
 
21
- /** The report games.fun records with the version: {pass, quick, passed, ran, failed:[{n, name}], at, kit, run}. */
22
+ /** The report xgames.fun records with the version: {pass, quick, passed, ran, failed:[{n, name}], at, kit, run}. */
22
23
  export function reportOf(summary, kit = null) {
23
24
  if (!summary) return { pass: false, quick: false, passed: 0, ran: 0, failed: [], at: null, kit, run: null };
24
25
  return {
@@ -54,6 +55,7 @@ export async function gate(
54
55
  const report = reportOf(summary, kit);
55
56
  if (after !== before)
56
57
  return { report: { ...report, pass: false }, fingerprint: after, ran: true, reused: false, changedDuring: true };
58
+ await stateFolder(dir);
57
59
  await mkdir(path.dirname(gateFile(dir)), { recursive: true });
58
60
  await writeFile(
59
61
  gateFile(dir),
@@ -1,4 +1,4 @@
1
- // What games.fun's play host does with a published game's files, as the kit checks it before a publish (pure: no Node
1
+ // What xgames.fun's play host does with a published game's files, as the kit checks it before a publish (pure: no Node
2
2
  // imports; the headers and types are the play origin's own, ../play-policy.mjs). README.md is the person's page for
3
3
  // it; ./scan.mjs holds the secret scan and the backend origins, which the service runs again on its side.
4
4
  //
@@ -7,7 +7,7 @@
7
7
  // the host's "not found" page (no fallback to index.html, so a deep link of a client-side router
8
8
  // works only from the entry page). No server-side rendering: code that runs on a server goes in
9
9
  // server/ (gf-server-v1, docs/SERVER.md), answering /api/*.
10
- // Where it is served at its own origin's root once published; under a folder in games.fun Studio's preview and in
10
+ // Where it is served at its own origin's root once published; under a folder in xgames.fun Studio's preview and in
11
11
  // the kit's dev server (/game/). Paths from the root (/assets/x.js) break there: a game uses
12
12
  // relative URLs.
13
13
  // Paths the host owns /api/* (the game's server, when it has one), /platform/v1/* (sign-in, saves, scores), the
@@ -21,7 +21,7 @@
21
21
  // Limits what GET /api/kit/me says (`limits`); DEFAULT_LIMITS when the kit cannot ask (offline).
22
22
  import { MIME, mimeFor, playHeaders } from '../play-policy.mjs';
23
23
 
24
- /** The host's limits for one published game, as games.fun states them today (README.md "What the hosting takes"); publish reads the account's own. */
24
+ /** The host's limits for one published game, as xgames.fun states them today (README.md "What the hosting takes"); publish reads the account's own. */
25
25
  export const DEFAULT_LIMITS = Object.freeze({
26
26
  files: 3000,
27
27
  totalBytes: 100 * 1024 * 1024,
@@ -65,8 +65,8 @@ export const UNTYPED_CODE = /\.(cjs|jsx|tsx?|vue|svelte|scss|sass|less)$/i;
65
65
  * already checked: lib/publish/scan.mjs backendOrigins): the play origin's own (lib/play-policy.mjs
66
66
  * playHeaders). The page check sends the same.
67
67
  */
68
- export const pageHeaders = (backend = [], mime = null) =>
69
- playHeaders({ studioOrigin: null, backend, ...(mime ? { mime } : {}) });
68
+ export const pageHeaders = (backend = [], mime = null, files = []) =>
69
+ playHeaders({ studioOrigin: null, backend, files, ...(mime ? { mime } : {}) });
70
70
  /** The page policy (Content-Security-Policy) alone. */
71
71
  export const pagePolicy = (backend = []) => pageHeaders(backend)['content-security-policy'];
72
72