@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.
- package/LICENSE +1 -1
- package/README.md +183 -84
- package/bin/xcore.mjs +13 -8
- package/commands/add.mjs +11 -8
- package/commands/adopt.mjs +9 -7
- package/commands/backend.mjs +2 -2
- package/commands/check.mjs +4 -4
- package/commands/db.mjs +218 -0
- package/commands/dev.mjs +8 -6
- package/commands/domain.mjs +8 -8
- package/commands/embed.mjs +3 -3
- package/commands/files.mjs +106 -0
- package/commands/login.mjs +9 -17
- package/commands/logout.mjs +15 -4
- package/commands/loot.mjs +2 -2
- package/commands/plan.mjs +22 -19
- package/commands/publish.mjs +54 -21
- package/commands/secret.mjs +6 -6
- package/docs/SERVER.md +218 -18
- package/lib/add.mjs +265 -40
- package/lib/browser.mjs +11 -2
- package/lib/check.mjs +10 -9
- package/lib/cli.mjs +24 -9
- package/lib/client/gamesfun.js +242 -122
- package/lib/client/godot/gamesfun.gd +17 -11
- package/lib/client/godot/gamesfun_peer.gd +2 -2
- package/lib/client/server/0001_init.sql +10 -0
- package/lib/client/server/database.js +271 -109
- package/lib/client/server/gf-db.js +55 -0
- package/lib/client/server/gf-docs.js +56 -0
- package/lib/client/server/rooms.js +16 -9
- package/lib/client/server/rpc.js +7 -7
- package/lib/client/server/stock.js +37 -0
- package/lib/client/server/vouchers.js +127 -46
- package/lib/client/unity/GamesFun.cs +15 -12
- package/lib/client/unity/GamesFun.jslib +1 -1
- package/lib/config-dir.mjs +1 -1
- package/lib/credentials.mjs +10 -5
- package/lib/db.mjs +355 -0
- package/lib/dev/d1.mjs +486 -0
- package/lib/dev/files.mjs +247 -0
- package/lib/dev/gf-local.js +69 -3
- package/lib/dev/index.mjs +68 -20
- package/lib/dev/limits.mjs +2 -2
- package/lib/dev/platform.mjs +111 -12
- package/lib/dev/server.mjs +54 -5
- package/lib/dev/tools.mjs +71 -18
- package/lib/files.mjs +208 -0
- package/lib/open.mjs +1 -1
- package/lib/play-policy.mjs +14 -5
- package/lib/publish/adopt.mjs +73 -33
- package/lib/publish/client.mjs +130 -20
- package/lib/publish/gate.mjs +4 -2
- package/lib/publish/hosting.mjs +5 -5
- package/lib/publish/index.mjs +162 -40
- package/lib/publish/oidc.mjs +26 -5
- package/lib/publish/page.mjs +1 -1
- package/lib/publish/plans.mjs +19 -12
- package/lib/publish/scan.mjs +78 -102
- package/lib/publish/secrets.mjs +4 -5
- package/lib/publish/site.mjs +9 -10
- package/lib/publish/tree.mjs +8 -8
- package/lib/secret-patterns.mjs +53 -16
- package/lib/secret-store.mjs +2 -2
- package/lib/server/bundle.mjs +113 -13
- package/lib/server/contract.mjs +440 -16
- package/lib/server/data-calls.mjs +136 -0
- package/lib/server/secrets.mjs +9 -7
- package/lib/solana-rpc.mjs +5 -5
- package/lib/state-folder.mjs +23 -0
- package/package.json +4 -4
- package/skills/bring-your-own-game/SKILL.md +10 -9
package/lib/publish/client.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Talking to
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
? `
|
|
194
|
-
: `
|
|
195
|
-
// `code`:
|
|
196
|
-
//
|
|
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
|
-
|
|
312
|
+
const download = downloadOf(value);
|
|
313
|
+
return download ? followDownload(download, fetchImpl) : value;
|
|
204
314
|
}
|
package/lib/publish/gate.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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),
|
package/lib/publish/hosting.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What
|
|
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
|
|
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
|
|
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
|
|