@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/scan.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// What a published game may carry. The kit checks these rules before a publish, for a fast answer;
|
|
1
|
+
// What a published game may carry. The kit checks these rules before a publish, for a fast answer; xgames.fun runs the
|
|
2
2
|
// same rules again on its side for every publish, whatever the kit already checked.
|
|
3
3
|
//
|
|
4
4
|
// 1. Secrets. A published game's files are public: anyone can download them. A file that
|
|
@@ -12,69 +12,15 @@
|
|
|
12
12
|
// The play host adds exactly those to that game's content-security policy (connect-src; the https ones also
|
|
13
13
|
// img-src and media-src) and nothing else; every other third-party request stays refused.
|
|
14
14
|
import { CMD } from '../command.mjs';
|
|
15
|
+
import { MAX_MATCH, SECRET_PATTERNS as ALL_PATTERNS } from '../secret-patterns.mjs';
|
|
15
16
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
],
|
|
24
|
-
['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,}/g],
|
|
25
|
-
['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g],
|
|
26
|
-
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/g],
|
|
27
|
-
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/g],
|
|
28
|
-
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/g],
|
|
29
|
-
['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/g],
|
|
30
|
-
['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,}/g],
|
|
31
|
-
['Supabase access token', /\bsbp_[A-Za-z0-9]{20,}/g],
|
|
32
|
-
['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]+:[^\s@/'"]+@/gi],
|
|
33
|
-
['games.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,}/g],
|
|
34
|
-
// providers whose keys have a shape of their own (each prefix, length and alphabet as the provider issues them)
|
|
35
|
-
['Hugging Face token', /\bhf_[A-Za-z0-9]{34,}\b/g],
|
|
36
|
-
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/g],
|
|
37
|
-
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/g],
|
|
38
|
-
['SendGrid key', /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/g],
|
|
39
|
-
['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,}/g],
|
|
40
|
-
['Shopify token', /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/g],
|
|
41
|
-
['Google OAuth client secret', /\bGOCSPX-[A-Za-z0-9_-]{28}\b/g],
|
|
42
|
-
['DigitalOcean token', /\bdo[opr]_v1_[a-f0-9]{64}\b/g],
|
|
43
|
-
['Telegram bot token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/g],
|
|
44
|
-
// a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key
|
|
45
|
-
[
|
|
46
|
-
'an RPC URL with its key (helius, alchemy, quicknode, infura)',
|
|
47
|
-
/https?:\/\/[^\s"'`]*(?:helius-rpc\.com[^\s"'`]*[?&]api-key=[A-Za-z0-9-]{16,}|\.alchemy\.com\/v2\/[A-Za-z0-9_-]{20,}|\.quiknode\.pro\/[A-Za-z0-9]{20,}|\.infura\.io\/v3\/[a-f0-9]{32})/g,
|
|
48
|
-
],
|
|
49
|
-
[
|
|
50
|
-
'a Solana keypair in code (fromSecretKey)',
|
|
51
|
-
/\bfromSecretKey\s*\(\s*(?:(?:new\s+Uint8Array|Uint8Array\.from|bs58\.decode)\s*\(\s*)?[["'`]/g,
|
|
52
|
-
],
|
|
53
|
-
[
|
|
54
|
-
'secret assigned a literal',
|
|
55
|
-
// not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText')
|
|
56
|
-
/(?<!\?\s*["']?)\b[A-Za-z0-9_]*(?:SECRET|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_TOKEN|SERVICE_ROLE|PASSWORD)[A-Za-z0-9_]*["']?\s*[:=]\s*["']([A-Za-z0-9+/_=.-]{16,})["']/gi,
|
|
57
|
-
],
|
|
58
|
-
// never touching + / = : those break up base64 data (an inlined picture), where a run of base58 letters is chance
|
|
59
|
-
// a signing key's 32-byte seed in hex, by its name (VOUCHER_SEED=…, voucherSeed: '…'): xcore add vouchers' format
|
|
60
|
-
[
|
|
61
|
-
'signing key seed (hex)',
|
|
62
|
-
/\b[A-Za-z0-9_]*(?:VOUCHER|WALLET|SIGNER|SIGNING|KEY)_?SEED[A-Za-z0-9_]*["']?\s*[:=]\s*["'`]?[0-9a-fA-F]{64}(?![0-9a-fA-F])/gi,
|
|
63
|
-
],
|
|
64
|
-
// a key file as a script writes one: {"pub": "<address>", "seed": "<64 hex>"} (either order)
|
|
65
|
-
[
|
|
66
|
-
'key file (pub and seed)',
|
|
67
|
-
/["']seed["']\s*:\s*["'][0-9a-fA-F]{64}["'][\s\S]{0,200}?["']pub(?:lic)?(?:Key)?["']\s*:|["']pub(?:lic)?(?:Key)?["']\s*:\s*["'][1-9A-HJ-NP-Za-km-z]{32,44}["'][\s\S]{0,200}?["']seed["']\s*:\s*["'][0-9a-fA-F]{64}["']/g,
|
|
68
|
-
],
|
|
69
|
-
// a 64-byte secret key written into code by its name (const SECRET_KEY = [12, 200, …] or new Uint8Array([…]))
|
|
70
|
-
[
|
|
71
|
-
'secret key bytes in code',
|
|
72
|
-
/\b[A-Za-z0-9_]*(?:SECRET|PRIVATE|KEYPAIR|SEED)[A-Za-z0-9_]*["']?\s*[:=]\s*(?:new\s+Uint8Array\(\s*)?\[\s*(?:\d{1,3}\s*,\s*){31,63}\d{1,3}\s*\]/gi,
|
|
73
|
-
],
|
|
74
|
-
['Solana secret key (base58)', /(?<![A-Za-z0-9+/])[1-9A-HJ-NP-Za-km-z]{86,90}(?![A-Za-z0-9+/=])/g],
|
|
75
|
-
];
|
|
76
|
-
const JWT = /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{16,}/g;
|
|
77
|
-
|
|
17
|
+
const global = re => new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
|
|
18
|
+
/** Key-shaped strings (../secret-patterns.mjs, the one list), each to run over a whole text. A JWT is read below. */
|
|
19
|
+
export const SECRET_PATTERNS = ALL_PATTERNS.filter(([kind]) => kind !== 'JSON web token').map(([kind, re]) => [
|
|
20
|
+
kind,
|
|
21
|
+
global(re),
|
|
22
|
+
]);
|
|
23
|
+
const JWT = global(ALL_PATTERNS.find(([kind]) => kind === 'JSON web token')[1]);
|
|
78
24
|
/** Files refused by name, wherever they are in the folder. */
|
|
79
25
|
export const REFUSED_FILES = [
|
|
80
26
|
[/(^|\/)\.env(\.[^/]*)?$/i, 'a .env file (secrets live there, never in a game)'],
|
|
@@ -93,7 +39,13 @@ export const isBinaryPath = path => BINARY_EXT.test(path);
|
|
|
93
39
|
/** Whether the scanner reads a file: known text extensions, or no NUL byte in the first 8 KB and not a known binary. */
|
|
94
40
|
export const looksText = (path, bytes) =>
|
|
95
41
|
TEXT_EXT.test(path) || (!BINARY_EXT.test(path) && !bytes.subarray(0, 8192).includes(0));
|
|
96
|
-
/** The
|
|
42
|
+
/** The secret scan reads a text file whole, this many bytes at a time (a bundled engine is many pieces). */
|
|
43
|
+
export const SCAN_CHUNK = 4 * 1024 * 1024;
|
|
44
|
+
/** How far each piece reaches into the next: more than the longest key a pattern can match. */
|
|
45
|
+
export const SCAN_OVERLAP = Math.max(64 * 1024, MAX_MATCH);
|
|
46
|
+
/** The text before a match that a pattern's look-behind and the checks read (a Firebase config is 400 characters). */
|
|
47
|
+
const SCAN_CONTEXT = 1024;
|
|
48
|
+
/** The reference-name check (./names.mjs) reads the first this many bytes of a text file. */
|
|
97
49
|
export const SCAN_BYTES = 4 * 1024 * 1024;
|
|
98
50
|
|
|
99
51
|
function base64urlJson(part) {
|
|
@@ -137,9 +89,11 @@ export const isSignature = (text, at) => SIGNATURE_KEY.test(text.slice(Math.max(
|
|
|
137
89
|
|
|
138
90
|
/**
|
|
139
91
|
* Every secret-shaped thing in one file: [{path, line, kind}]. Never returns the value. `bytes` is a Uint8Array.
|
|
140
|
-
* Binary files are not read (a key inside a PNG is not a thing anyone ships by accident).
|
|
92
|
+
* Binary files are not read (a key inside a PNG is not a thing anyone ships by accident). A text file is read whole,
|
|
93
|
+
* however big, a piece at a time: each piece goes on SCAN_OVERLAP characters past where the next one starts, more than
|
|
94
|
+
* any pattern can match (MAX_MATCH), so a key across the line between two pieces is found as well.
|
|
141
95
|
*/
|
|
142
|
-
export function scanFile(path, bytes) {
|
|
96
|
+
export function scanFile(path, bytes, { chunk = SCAN_CHUNK, overlap = SCAN_OVERLAP } = {}) {
|
|
143
97
|
const hits = [];
|
|
144
98
|
for (const [re, why] of REFUSED_FILES)
|
|
145
99
|
if (re.test(path)) {
|
|
@@ -147,44 +101,66 @@ export function scanFile(path, bytes) {
|
|
|
147
101
|
break;
|
|
148
102
|
}
|
|
149
103
|
if (!looksText(path, bytes)) return hits;
|
|
150
|
-
const
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
: role
|
|
180
|
-
? `a signed token with role "${role}"`
|
|
181
|
-
: 'a signed token (JWT)',
|
|
104
|
+
const found = new Map(); // a pattern's place in the list → its first hit: one hit per kind per file refuses it
|
|
105
|
+
const decoder = new TextDecoder();
|
|
106
|
+
let carry = '', // the end of the last piece: the overlap, and the text just before it that a check reads
|
|
107
|
+
from = 0, // where the matches this piece owns start in it (earlier ones were the last piece's)
|
|
108
|
+
lines = 0; // the lines before this piece
|
|
109
|
+
for (let at = 0; at < bytes.length || at === 0; at += chunk) {
|
|
110
|
+
const last = at + chunk >= bytes.length;
|
|
111
|
+
const text = carry + decoder.decode(bytes.subarray(at, at + chunk), { stream: !last });
|
|
112
|
+
// a match starting in [from, to) is this piece's; one starting later is the next piece's, which sees all of it
|
|
113
|
+
const to = last ? text.length : Math.max(from, text.length - overlap);
|
|
114
|
+
const lineOf = i => lines + lineAt(text, i);
|
|
115
|
+
SECRET_PATTERNS.forEach(([kind, re], n) => {
|
|
116
|
+
if (found.has(n)) return;
|
|
117
|
+
re.lastIndex = from;
|
|
118
|
+
for (let m; (m = re.exec(text)) && m.index < to;) {
|
|
119
|
+
if (kind === 'secret assigned a literal' && isPublicKey(m[1])) continue;
|
|
120
|
+
if (kind === 'Google API key' && isFirebaseWebKey(text, m.index)) continue;
|
|
121
|
+
if (
|
|
122
|
+
kind === 'secret assigned a literal' &&
|
|
123
|
+
/^AIza/.test(m[1]) &&
|
|
124
|
+
isFirebaseWebKey(text, m.index + m[0].lastIndexOf(m[1]))
|
|
125
|
+
)
|
|
126
|
+
continue;
|
|
127
|
+
// a real key is random: a run of one letter (a tile map row) is not one; and a transaction's signature is the
|
|
128
|
+
// same length (an economy's xsolana.json records the ones that put it live, and a server that imports it carries them)
|
|
129
|
+
if (kind === 'Solana secret key (base58)' && (new Set(m[0]).size < 20 || isSignature(text, m.index))) continue;
|
|
130
|
+
found.set(n, { path, line: lineOf(m.index), kind });
|
|
131
|
+
break;
|
|
132
|
+
}
|
|
182
133
|
});
|
|
183
|
-
|
|
134
|
+
if (!found.has(SECRET_PATTERNS.length)) {
|
|
135
|
+
JWT.lastIndex = from;
|
|
136
|
+
for (let m; (m = JWT.exec(text)) && m.index < to;) {
|
|
137
|
+
const role = jwtRole(m[0]);
|
|
138
|
+
if (role === 'anon') continue;
|
|
139
|
+
found.set(SECRET_PATTERNS.length, {
|
|
140
|
+
path,
|
|
141
|
+
line: lineOf(m.index),
|
|
142
|
+
kind:
|
|
143
|
+
role === 'service_role'
|
|
144
|
+
? 'Supabase service-role key (it bypasses row-level security: never in a game)'
|
|
145
|
+
: role
|
|
146
|
+
? `a signed token with role "${role}"`
|
|
147
|
+
: 'a signed token (JWT)',
|
|
148
|
+
});
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
if (last) break;
|
|
153
|
+
// the next piece starts with the overlap, and the text before it that a check looks back over
|
|
154
|
+
const keep = Math.max(0, to - SCAN_CONTEXT);
|
|
155
|
+
lines += text.slice(0, keep).split('\n').length - 1;
|
|
156
|
+
carry = text.slice(keep);
|
|
157
|
+
from = to - keep;
|
|
184
158
|
}
|
|
185
|
-
|
|
159
|
+
hits.push(...[...found].sort(([a], [b]) => a - b).map(([, h]) => h));
|
|
160
|
+
// a wallet keypair file is a small JSON array: read whole when it is small enough to be one
|
|
161
|
+
if (/\.json$/i.test(path) && bytes.length <= 64 * 1024) {
|
|
186
162
|
try {
|
|
187
|
-
const v = JSON.parse(
|
|
163
|
+
const v = JSON.parse(new TextDecoder().decode(bytes));
|
|
188
164
|
if (Array.isArray(v) && v.length === 64 && v.every(n => Number.isInteger(n) && n >= 0 && n < 256))
|
|
189
165
|
hits.push({ path, line: 1, kind: 'a wallet keypair (64-byte array)' });
|
|
190
166
|
} catch {
|
|
@@ -249,7 +225,7 @@ export function backendOrigin(value, { own = [] } = {}) {
|
|
|
249
225
|
}
|
|
250
226
|
})
|
|
251
227
|
)
|
|
252
|
-
throw bad('is
|
|
228
|
+
throw bad('is xgames.fun itself; the game reaches its own host without listing it');
|
|
253
229
|
return url.origin;
|
|
254
230
|
}
|
|
255
231
|
|
package/lib/publish/secrets.mjs
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
// `xcore secret`'s calls to
|
|
1
|
+
// `xcore secret`'s calls to xgames.fun (the rules: ../server/secrets.mjs; xgames.fun's hosting contract, gf-server-v1):
|
|
2
2
|
// GET /api/kit/games/<gameId>/secrets → {ok, game, names}
|
|
3
3
|
// PUT /api/kit/games/<gameId>/secrets/<NAME> {"value"} → {ok, game, name, names}
|
|
4
4
|
// DELETE /api/kit/games/<gameId>/secrets/<NAME> → {ok, game, name, removed, names}
|
|
5
5
|
// The game is the folder's own (its .gamesfun/publish.json for this Studio); the credential is publish's.
|
|
6
6
|
import { readLinks } from './index.mjs';
|
|
7
7
|
import { call, studioOrigin } from './client.mjs';
|
|
8
|
-
import {
|
|
8
|
+
import { requireCredential } from './oidc.mjs';
|
|
9
9
|
import { secretNameProblem, secretValueProblem } from '../server/secrets.mjs';
|
|
10
10
|
import { CMD } from '../command.mjs';
|
|
11
11
|
|
|
12
|
-
/** The game's id on
|
|
12
|
+
/** The game's id on xgames.fun (from its publish record) and how to sign the call. Throws in plain words. (from its publish record) and how to sign the call. Throws in plain words. */
|
|
13
13
|
async function target(dir, { origin, env, fetchImpl }) {
|
|
14
14
|
const known = (await readLinks(dir))[origin];
|
|
15
15
|
if (!known?.projectId)
|
|
16
16
|
throw new Error(
|
|
17
17
|
`this folder's game is not on ${origin} yet: publish it with its server/ first (${CMD} publish), then set its secrets`,
|
|
18
18
|
);
|
|
19
|
-
const cred = await
|
|
20
|
-
if (!cred) throw new Error(`not signed in to ${origin}: run ${CMD} login first`);
|
|
19
|
+
const cred = await requireCredential(origin, { env, fetchImpl });
|
|
21
20
|
return { id: known.projectId, auth: await cred.auth() };
|
|
22
21
|
}
|
|
23
22
|
const route = (id, name) =>
|
package/lib/publish/site.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// `xcore embed` and `xcore domain`'s calls to
|
|
2
|
-
// own that embed it) and the domains it is served on besides <slug>-play.xgames.fun.
|
|
1
|
+
// `xcore embed` and `xcore domain`'s calls to xgames.fun: where a published game may be framed (sites of the creator's
|
|
2
|
+
// own that embed it) and the domains it is served on besides <slug>-play.xgames.fun. xgames.fun's routes:
|
|
3
3
|
// GET /api/kit/games/<gameId>/site → the site
|
|
4
4
|
// PUT /api/kit/games/<gameId>/site {"embed": [origin…]} → the site (the whole list, replaced)
|
|
5
5
|
// POST /api/kit/games/<gameId>/site/domains {"hostname"} → the site (one added, pending its DNS records)
|
|
@@ -7,27 +7,26 @@
|
|
|
7
7
|
// DELETE /api/kit/games/<gameId>/site/domains?hostname=<hostname> → the site
|
|
8
8
|
// The site: {ok, game, slug, tier, limits: {embed, domains}, embed: [origin], domains: [{hostname, status, records,
|
|
9
9
|
// error?, url, checkedAt?, activeAt?}], domainsOn, why?}. Refusals carry a code: 'plan' (over the plan's limit),
|
|
10
|
-
// 'taken' (a domain another game has), 'domains-off' (
|
|
10
|
+
// 'taken' (a domain another game has), 'domains-off' (xgames.fun has not set custom domains up).
|
|
11
11
|
// The game is the folder's own (its .gamesfun/publish.json for this Studio); the credential is publish's.
|
|
12
12
|
import { readLinks } from './index.mjs';
|
|
13
13
|
import { call, studioOrigin } from './client.mjs';
|
|
14
|
-
import {
|
|
14
|
+
import { requireCredential } from './oidc.mjs';
|
|
15
15
|
import { CMD } from '../command.mjs';
|
|
16
16
|
|
|
17
17
|
async function target(dir, { origin, env, fetchImpl }) {
|
|
18
18
|
const known = (await readLinks(dir))[origin];
|
|
19
19
|
if (!known?.projectId)
|
|
20
20
|
throw new Error(`this folder's game is not on ${origin} yet: publish it first (${CMD} publish)`);
|
|
21
|
-
const cred = await
|
|
22
|
-
if (!cred) throw new Error(`not signed in to ${origin}: run ${CMD} login first`);
|
|
21
|
+
const cred = await requireCredential(origin, { env, fetchImpl });
|
|
23
22
|
return { id: known.projectId, auth: await cred.auth() };
|
|
24
23
|
}
|
|
25
24
|
const route = (id, rest = '') => `/api/kit/games/${encodeURIComponent(id)}/site${rest}`;
|
|
26
|
-
/** A
|
|
25
|
+
/** A xgames.fun older than these routes answers 404 with no words of its own: said plainly. */
|
|
27
26
|
function older(e) {
|
|
28
27
|
if (e?.status === 404 && e.answered === false)
|
|
29
28
|
throw Object.assign(
|
|
30
|
-
new Error(`this
|
|
29
|
+
new Error(`this xgames.fun does not offer embedding and custom domains yet (it needs a newer xgames.fun).`),
|
|
31
30
|
{ status: 404 },
|
|
32
31
|
);
|
|
33
32
|
throw e;
|
|
@@ -73,7 +72,7 @@ export function domainName(value) {
|
|
|
73
72
|
)
|
|
74
73
|
return { problem: `${value} is not a domain name (like play.mygame.com)` };
|
|
75
74
|
if (raw === 'xgames.fun' || raw.endsWith('.xgames.fun'))
|
|
76
|
-
return { problem: `${value} is
|
|
75
|
+
return { problem: `${value} is xgames.fun's own: every game has its xgames.fun link already` };
|
|
77
76
|
return { hostname: raw };
|
|
78
77
|
}
|
|
79
78
|
|
|
@@ -182,7 +181,7 @@ export function describeSite(site, origin = studioOrigin()) {
|
|
|
182
181
|
? `Embedded on: ${site.embed.join(', ')} (${site.embed.length} of ${lim.embed ?? '?'} on ${tier})`
|
|
183
182
|
: `Embedded on: no sites yet (${tier} allows ${lim.embed ?? '?'}: ${CMD} embed add https://mygame.com)`,
|
|
184
183
|
);
|
|
185
|
-
if (site.domainsOn === false) lines.push(`Custom domains: ${site.why ?? 'not set up on this
|
|
184
|
+
if (site.domainsOn === false) lines.push(`Custom domains: ${site.why ?? 'not set up on this xgames.fun yet'}`);
|
|
186
185
|
else if (!site.domains?.length)
|
|
187
186
|
lines.push(
|
|
188
187
|
lim.domains
|
package/lib/publish/tree.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// The files `xcore publish` sends: a game folder as
|
|
2
|
-
// every file; the bytes go up separately, only those
|
|
1
|
+
// The files `xcore publish` sends: a game folder as xgames.fun's file tree (gf-tree-v1: path, sha256, bytes for
|
|
2
|
+
// every file; the bytes go up separately, only those xgames.fun does not have yet).
|
|
3
3
|
//
|
|
4
4
|
// Left out, never uploaded: evidence/ (verify's screenshots and verdicts), supabase/ (a backend's server-side SQL:
|
|
5
5
|
// not secret, but not the game; `xcore backend`), server/ (the game's own server code:
|
|
@@ -10,7 +10,7 @@
|
|
|
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
12
|
// Refused (nothing is sent): a .env file anywhere in the folder, and symbolic links (a tree holds ordinary files).
|
|
13
|
-
// Added, in the upload only: foundation.lock.json and adaptation-plan.json when the game has none (
|
|
13
|
+
// Added, in the upload only: foundation.lock.json and adaptation-plan.json when the game has none (xgames.fun keeps
|
|
14
14
|
// 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
15
|
// spec.json, and the lock is the same for every kit publish, so each update of a game keeps it.
|
|
16
16
|
// Copied, in the upload only: spec.json without the names of the games it was made to look or play like
|
|
@@ -30,9 +30,9 @@ export const LEFT_OUT_FILES = ['gamesfun.json'];
|
|
|
30
30
|
/** The notes a game folder keeps for the people and agents making it, at its top: never the game. */
|
|
31
31
|
export const MAKERS_NOTES = /^(README|NOTES|LAUNCH|FRICTION|CLAUDE|AGENTS|TODO)\.md$/i;
|
|
32
32
|
export const TREE_FORMAT = 'gf-tree-v1';
|
|
33
|
-
// Every publish sends this lock, a brought game's too.
|
|
33
|
+
// Every publish sends this lock, a brought game's too. xgames.fun refuses a revision whose lock differs from the game's
|
|
34
34
|
// ("A revision must preserve its foundation lock"), so changing it here would refuse the next update of every game
|
|
35
|
-
// already published (tried live, 2026-10-31). A truer lock for brought games needs
|
|
35
|
+
// already published (tried live, 2026-10-31). A truer lock for brought games needs xgames.fun to accept the change.
|
|
36
36
|
export const KIT_LOCK = Object.freeze({
|
|
37
37
|
schemaVersion: 1,
|
|
38
38
|
id: 'gf-three',
|
|
@@ -41,7 +41,7 @@ export const KIT_LOCK = Object.freeze({
|
|
|
41
41
|
});
|
|
42
42
|
|
|
43
43
|
export const sha256 = data => createHash('sha256').update(data).digest('hex');
|
|
44
|
-
/** Canonical JSON (sorted keys), as
|
|
44
|
+
/** Canonical JSON (sorted keys), as xgames.fun hashes trees. */
|
|
45
45
|
export function canonical(value) {
|
|
46
46
|
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
47
47
|
if (Array.isArray(value)) return '[' + value.map(canonical).join(',') + ']';
|
|
@@ -70,7 +70,7 @@ export async function referenceNamesOf(dir) {
|
|
|
70
70
|
return referenceNames({ spec, ledger: state?.ledger ?? null });
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
-
/** The two files
|
|
73
|
+
/** The two files xgames.fun keeps with every version, made from the game's own records (`names` taken out). */
|
|
74
74
|
export async function kitRecords(dir, { names = [] } = {}) {
|
|
75
75
|
const raw = (await readJson(path.join(dir, 'spec.json'))) ?? {};
|
|
76
76
|
const spec = names.length ? redactJson(raw, names) : raw;
|
|
@@ -87,7 +87,7 @@ export async function kitRecords(dir, { names = [] } = {}) {
|
|
|
87
87
|
brief: String(
|
|
88
88
|
spec.prompt ||
|
|
89
89
|
spec.hook ||
|
|
90
|
-
(brought ? `${title}, a web game brought to the
|
|
90
|
+
(brought ? `${title}, a web game brought to the xgames.fun kit.` : `${title}, made with the xgames.fun kit.`),
|
|
91
91
|
).slice(0, 2000),
|
|
92
92
|
requirements: [
|
|
93
93
|
spec.hook,
|
package/lib/secret-patterns.mjs
CHANGED
|
@@ -1,39 +1,76 @@
|
|
|
1
|
-
// Key-shaped strings: the
|
|
2
|
-
//
|
|
1
|
+
// Key-shaped strings: the one list of them. The publish scan (./publish/scan.mjs) reads every pattern here, and the
|
|
2
|
+
// kit imports SECRET_PATTERNS for its own scanner. Pure: no imports.
|
|
3
|
+
//
|
|
4
|
+
// Every run in a pattern has an upper bound, so no match is longer than MAX_MATCH characters: the scan reads a big
|
|
5
|
+
// file in pieces that overlap by more than that, and finds a key that straddles two pieces (tests/scan.test.mjs checks
|
|
6
|
+
// each pattern's longest match against it). The bounds are far past any real key's length (a JWT with a large payload,
|
|
7
|
+
// a long secret pasted into code), so bounding a pattern never lets one through that an unbounded one caught.
|
|
8
|
+
|
|
9
|
+
/** The longest text any pattern here can match (the JWT's: 4,096 + 16,384 + 2,048 and its marks). */
|
|
10
|
+
export const MAX_MATCH = 32 * 1024;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* [kind, pattern]. Each kind is the words a refusal names it by. The last, 'JSON web token', is any JWT: the publish
|
|
14
|
+
* scan reads a JWT's role instead (a Supabase anon key is public by design), and the kit replaces it the same way.
|
|
15
|
+
*/
|
|
3
16
|
export const SECRET_PATTERNS = [
|
|
4
|
-
['private key block', /-----BEGIN (?:[A-Z]
|
|
17
|
+
['private key block', /-----BEGIN (?:[A-Z]{1,20} )?PRIVATE KEY-----/],
|
|
18
|
+
// not inside a word or a class name (style-sk-aria-pressed): a key stands on its own
|
|
5
19
|
[
|
|
6
20
|
'OpenAI or Anthropic key',
|
|
7
|
-
/(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,}|[A-Za-z0-9]{32,})/,
|
|
21
|
+
/(?<![A-Za-z0-9_-])sk-(?:(?:proj|ant|svcacct|admin)-[A-Za-z0-9_-]{20,500}|[A-Za-z0-9]{32,500})/,
|
|
8
22
|
],
|
|
9
|
-
['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,}/],
|
|
23
|
+
['Meshy key', /\bmsy_[A-Za-z0-9_-]{16,500}/],
|
|
10
24
|
['AWS access key', /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/],
|
|
11
|
-
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,}|github_pat_[A-Za-z0-9_]{30,})/],
|
|
12
|
-
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,}/],
|
|
25
|
+
['GitHub token', /\b(?:gh[pousr]_[A-Za-z0-9]{30,500}|github_pat_[A-Za-z0-9_]{30,500})/],
|
|
26
|
+
['Slack token', /\bxox[abprs]-[A-Za-z0-9-]{10,500}/],
|
|
13
27
|
['Google API key', /\bAIza[0-9A-Za-z_-]{35}\b/],
|
|
14
|
-
['Stripe key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,}/],
|
|
15
|
-
['
|
|
28
|
+
['Stripe secret key', /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,500}/],
|
|
29
|
+
['Supabase secret key', /\bsb_secret_[A-Za-z0-9_-]{16,500}/],
|
|
30
|
+
['Supabase access token', /\bsbp_[A-Za-z0-9]{20,500}/],
|
|
31
|
+
['database address with a password', /\bpostgres(?:ql)?:\/\/[^\s:@/'"]{1,200}:[^\s@/'"]{1,200}@/i],
|
|
32
|
+
['xgames.fun publish token', /\bgfk_[A-Za-z0-9_-]{30,500}/],
|
|
33
|
+
// providers whose keys have a shape of their own (each prefix, length and alphabet as the provider issues them)
|
|
34
|
+
['Hugging Face token', /\bhf_[A-Za-z0-9]{34,500}\b/],
|
|
16
35
|
['npm token', /\bnpm_[A-Za-z0-9]{36}\b/],
|
|
17
|
-
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,}/],
|
|
36
|
+
['GitLab token', /\bglpat-[A-Za-z0-9_-]{20,500}/],
|
|
18
37
|
['SendGrid key', /\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b/],
|
|
19
|
-
['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,}/],
|
|
38
|
+
['Stripe webhook secret', /\bwhsec_[A-Za-z0-9]{32,500}/],
|
|
20
39
|
['Shopify token', /\bshp(?:at|ca|pa|ss)_[a-fA-F0-9]{32}\b/],
|
|
21
40
|
['Google OAuth client secret', /\bGOCSPX-[A-Za-z0-9_-]{28}\b/],
|
|
22
41
|
['DigitalOcean token', /\bdo[opr]_v1_[a-f0-9]{64}\b/],
|
|
23
42
|
['Telegram bot token', /\b\d{8,10}:AA[A-Za-z0-9_-]{33}\b/],
|
|
43
|
+
// a game's page never holds an RPC URL (it calls its own /loot/v1/rpc): one with a key in it is that key
|
|
24
44
|
[
|
|
25
|
-
'an RPC URL with its key',
|
|
26
|
-
/https?:\/\/[^\s"'`]
|
|
45
|
+
'an RPC URL with its key (helius, alchemy, quicknode, infura)',
|
|
46
|
+
/https?:\/\/[^\s"'`]{0,1000}(?:helius-rpc\.com[^\s"'`]{0,300}[?&]api-key=[A-Za-z0-9-]{16,200}|\.alchemy\.com\/v2\/[A-Za-z0-9_-]{20,200}|\.quiknode\.pro\/[A-Za-z0-9]{20,200}|\.infura\.io\/v3\/[a-f0-9]{32})/,
|
|
27
47
|
],
|
|
28
48
|
[
|
|
29
49
|
'a Solana keypair in code (fromSecretKey)',
|
|
30
|
-
/\bfromSecretKey\s
|
|
50
|
+
/\bfromSecretKey\s{0,20}\(\s{0,20}(?:(?:new\s{1,20}Uint8Array|Uint8Array\.from|bs58\.decode)\s{0,20}\(\s{0,20})?[["'`]/,
|
|
31
51
|
],
|
|
32
|
-
['JSON web token', /\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{16,}/],
|
|
33
52
|
[
|
|
34
53
|
'secret assigned a literal',
|
|
35
|
-
|
|
54
|
+
// not the first branch of a ternary (cond ? 'MySecretsText' : 'OtherText'); the value is its first group
|
|
55
|
+
/(?<!\?\s*["']?)\b[A-Za-z0-9_]{0,100}(?:SECRET|API_KEY|APIKEY|PRIVATE_KEY|ACCESS_TOKEN|SERVICE_ROLE|PASSWORD)[A-Za-z0-9_]{0,100}["']?\s{0,20}[:=]\s{0,20}["']([A-Za-z0-9+/_=.-]{16,16384})["']/i,
|
|
56
|
+
],
|
|
57
|
+
// a signing key's 32-byte seed in hex, by its name (VOUCHER_SEED=…, voucherSeed: '…'): xcore add vouchers' format
|
|
58
|
+
[
|
|
59
|
+
'signing key seed (hex)',
|
|
60
|
+
/\b[A-Za-z0-9_]{0,100}(?:VOUCHER|WALLET|SIGNER|SIGNING|KEY)_?SEED[A-Za-z0-9_]{0,100}["']?\s{0,20}[:=]\s{0,20}["'`]?[0-9a-fA-F]{64}(?![0-9a-fA-F])/i,
|
|
61
|
+
],
|
|
62
|
+
// a key file as a script writes one: {"pub": "<address>", "seed": "<64 hex>"} (either order)
|
|
63
|
+
[
|
|
64
|
+
'key file (pub and seed)',
|
|
65
|
+
/["']seed["']\s{0,20}:\s{0,20}["'][0-9a-fA-F]{64}["'][\s\S]{0,200}?["']pub(?:lic)?(?:Key)?["']\s{0,20}:|["']pub(?:lic)?(?:Key)?["']\s{0,20}:\s{0,20}["'][1-9A-HJ-NP-Za-km-z]{32,44}["'][\s\S]{0,200}?["']seed["']\s{0,20}:\s{0,20}["'][0-9a-fA-F]{64}["']/,
|
|
66
|
+
],
|
|
67
|
+
// a 64-byte secret key written into code by its name (const SECRET_KEY = [12, 200, …] or new Uint8Array([…]))
|
|
68
|
+
[
|
|
69
|
+
'secret key bytes in code',
|
|
70
|
+
/\b[A-Za-z0-9_]{0,100}(?:SECRET|PRIVATE|KEYPAIR|SEED)[A-Za-z0-9_]{0,100}["']?\s{0,20}[:=]\s{0,20}(?:new\s{1,20}Uint8Array\(\s{0,20})?\[\s{0,20}(?:\d{1,3}\s{0,20},\s{0,20}){31,63}\d{1,3}\s{0,20}\]/i,
|
|
36
71
|
],
|
|
37
72
|
// never touching + / = : those break up base64 data (an inlined picture), where a run of base58 letters is chance
|
|
38
73
|
['Solana secret key (base58)', /(?<![A-Za-z0-9+/])[1-9A-HJ-NP-Za-km-z]{86,90}(?![A-Za-z0-9+/=])/],
|
|
74
|
+
// header, payload (16K: a token with many claims), signature (an RS512 one with a 8192-bit key is 1,366)
|
|
75
|
+
['JSON web token', /\beyJ[A-Za-z0-9_-]{10,4096}\.eyJ[A-Za-z0-9_-]{10,16384}\.[A-Za-z0-9_-]{16,2048}/],
|
|
39
76
|
];
|
package/lib/secret-store.mjs
CHANGED
|
@@ -126,7 +126,7 @@ const macStore = sec => ({
|
|
|
126
126
|
const r = run(
|
|
127
127
|
sec,
|
|
128
128
|
['-i'],
|
|
129
|
-
`add-generic-password -U -s ${SERVICE} -a ${name} -l "
|
|
129
|
+
`add-generic-password -U -s ${SERVICE} -a ${name} -l "xgames.fun kit ${name}" -w ${b64}\n`,
|
|
130
130
|
);
|
|
131
131
|
if (r.status !== 0 || /error/i.test(r.stderr ?? ''))
|
|
132
132
|
throw new Error(`the keychain refused the sign-in: ${(r.stderr || '').trim().slice(0, 200)}`);
|
|
@@ -145,7 +145,7 @@ const libsecretStore = tool => ({
|
|
|
145
145
|
return r.status === 0 && r.stdout ? r.stdout : null;
|
|
146
146
|
},
|
|
147
147
|
set(name, value) {
|
|
148
|
-
const r = run(tool, ['store', `--label=
|
|
148
|
+
const r = run(tool, ['store', `--label=xgames.fun kit ${name}`, 'service', SERVICE, 'account', name], value);
|
|
149
149
|
if (r.status !== 0) throw new Error(`the keyring refused the sign-in: ${(r.stderr || '').trim().slice(0, 200)}`);
|
|
150
150
|
},
|
|
151
151
|
delete(name) {
|