@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
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
# GamesFun: sign-in, saves, leaderboards and the game's own database on
|
|
1
|
+
# GamesFun: sign-in, saves, leaderboards and the game's own database on xgames.fun, for a Godot 4 Web export.
|
|
2
2
|
# Written by `xcore add signin` (autoloaded as GamesFun in project.godot); `xcore add signin` again brings it up to date.
|
|
3
|
-
# It loads
|
|
3
|
+
# It loads xgames.fun's page client (gamesfun.js, carried inside this file) into the page and calls it. Every call
|
|
4
4
|
# answers a Dictionary {data, error, status}; outside a Web export (the editor, a desktop build) the error says so and
|
|
5
|
-
# the game plays on. Export for the Web with Thread Support off:
|
|
5
|
+
# the game plays on. Export for the Web with Thread Support off: xgames.fun pages are not cross-origin isolated.
|
|
6
6
|
#
|
|
7
7
|
# var player = await GamesFun.sign_in() # from a button press: the player signs in -> Dictionary or null
|
|
8
8
|
# GamesFun.player_changed.connect(func(p): ...) # sign-in and sign-out (p: Dictionary or null)
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
# await GamesFun.scores_submit("best", 1200) # leaderboards: r.data {improved, best, rank}
|
|
13
13
|
# var top = await GamesFun.scores_top("best") # top.data: [{rank, player, name, score, at}]
|
|
14
14
|
# await GamesFun.db_add("levels", {"name": "Moon"}) # the game's own database (xcore add database)
|
|
15
|
-
# multiplayer.multiplayer_peer = await GamesFun.join_room(code) # Godot's multiplayer over
|
|
15
|
+
# multiplayer.multiplayer_peer = await GamesFun.join_room(code) # Godot's multiplayer over xgames.fun (xcore add multiplayer)
|
|
16
16
|
# await GamesFun.loot_call("shop") # the game's Solana economy (xcore loot add): loot_ready, loot_connect
|
|
17
17
|
#
|
|
18
18
|
# A script that also runs without the autoload (headless tests, `godot --script`) fails to parse on the bare name
|
|
@@ -113,7 +113,7 @@ func _set_player(p):
|
|
|
113
113
|
|
|
114
114
|
func _run(path: String, args: Array):
|
|
115
115
|
if _bridge == null:
|
|
116
|
-
return {"data": null, "error": "
|
|
116
|
+
return {"data": null, "error": "xgames.fun runs in a Web export only", "status": 0}
|
|
117
117
|
var pending = _Pending.new()
|
|
118
118
|
_keep.append(pending)
|
|
119
119
|
_bridge.run(path, JSON.stringify(args), pending.callback)
|
|
@@ -168,8 +168,10 @@ func scores_mine(board: String):
|
|
|
168
168
|
return await _run("scores.mine", [board])
|
|
169
169
|
|
|
170
170
|
|
|
171
|
-
|
|
172
|
-
|
|
171
|
+
## opts: {"owner": "me" | "server" | a player's id}; without it, the player's own. Another player's document is read
|
|
172
|
+
## by its owner: db_get(c, id, {"owner": doc.owner})
|
|
173
|
+
func db_get(collection: String, id: String, opts := {}):
|
|
174
|
+
return await _run("db.get", [collection, id, opts])
|
|
173
175
|
|
|
174
176
|
|
|
175
177
|
func db_put(collection: String, id: String, value):
|
|
@@ -180,8 +182,12 @@ func db_add(collection: String, value):
|
|
|
180
182
|
return await _run("db.add", [collection, value])
|
|
181
183
|
|
|
182
184
|
|
|
183
|
-
## opts: {"owner": "me", "limit": 50, "
|
|
185
|
+
## opts: {"owner": "me" | "server" | a player's id, "limit": 50, "after": the last answer's "after", "order": "new" | "old"}
|
|
186
|
+
## The answer's "after" is null on the last page. An "offset" but 0 is refused (it is gone: page with "after"), here as
|
|
187
|
+
## well, so a loop that paged with it stops in the editor too.
|
|
184
188
|
func db_list(collection: String, opts := {}):
|
|
189
|
+
if opts.has("offset") and opts["offset"] != null and str(opts["offset"]) != "" and float(str(opts["offset"])) != 0.0:
|
|
190
|
+
return {"data": null, "error": "db_list takes no offset any more: pass the last answer's after for the next page", "status": 0, "code": "offset"}
|
|
185
191
|
return await _run("db.list", [collection, opts])
|
|
186
192
|
|
|
187
193
|
|
|
@@ -241,7 +247,7 @@ func room_code() -> String:
|
|
|
241
247
|
return s
|
|
242
248
|
|
|
243
249
|
|
|
244
|
-
## The room this page was opened for (
|
|
250
|
+
## The room this page was opened for (xgames.fun's room links and their X cards pass ?room=CODE), or "".
|
|
245
251
|
func room_from_link() -> String:
|
|
246
252
|
if _bridge == null:
|
|
247
253
|
return ""
|
|
@@ -249,7 +255,7 @@ func room_from_link() -> String:
|
|
|
249
255
|
return str(c) if c != null else ""
|
|
250
256
|
|
|
251
257
|
|
|
252
|
-
## The link that opens the game in this room (
|
|
258
|
+
## The link that opens the game in this room (xgames.fun's play page once published; its X card shows the room).
|
|
253
259
|
func room_link(code: String) -> String:
|
|
254
260
|
if _bridge == null:
|
|
255
261
|
return ""
|
|
@@ -304,5 +310,5 @@ func api(path: String, init := {}):
|
|
|
304
310
|
return await _run("api", [path, init])
|
|
305
311
|
|
|
306
312
|
|
|
307
|
-
#
|
|
313
|
+
# xgames.fun's page client (lib/client/gamesfun.js), as base64
|
|
308
314
|
const SOURCE = "__GAMESFUN_SOURCE_B64__"
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
# GamesFunPeer:
|
|
2
|
-
# MultiplayerSpawner, MultiplayerSynchronizer, multiplayer.peer_connected …) runs over
|
|
1
|
+
# GamesFunPeer: an xgames.fun room as Godot's MultiplayerPeer, so a game's high-level multiplayer (RPCs,
|
|
2
|
+
# MultiplayerSpawner, MultiplayerSynchronizer, multiplayer.peer_connected …) runs over xgames.fun as it runs over ENet.
|
|
3
3
|
# Written by `xcore add multiplayer`; made by GamesFun.join_room(), never by hand:
|
|
4
4
|
#
|
|
5
5
|
# var peer = await GamesFun.join_room(GamesFun.room_from_link() if GamesFun.room_from_link() != "" else GamesFun.room_code())
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
-- The game's first tables, written by `xcore add database`: an example to change before the game is published.
|
|
2
|
+
-- xgames.fun applies each migration once, in name order, before the version that brings it goes live; `xcore dev`
|
|
3
|
+
-- does the same on this machine. Once this one is published, never change it: put the next change in
|
|
4
|
+
-- 0002_<what>.sql (ALTER TABLE players ADD COLUMN …).
|
|
5
|
+
CREATE TABLE players (
|
|
6
|
+
id TEXT PRIMARY KEY, -- the player's id: env.GF.verifyPlayer's sub (withPlayer in gf-db.js)
|
|
7
|
+
gold INTEGER NOT NULL DEFAULT 0,
|
|
8
|
+
runs INTEGER NOT NULL DEFAULT 3, -- runs left today
|
|
9
|
+
updated_at INTEGER NOT NULL DEFAULT (strftime('%s', 'now'))
|
|
10
|
+
);
|
|
@@ -1,181 +1,304 @@
|
|
|
1
|
-
// The game's own database on
|
|
1
|
+
// The game's own database on xgames.fun, written into server/ by `xcore add database`. It is part of the game's server
|
|
2
2
|
// code (docs/SERVER.md): one Durable Object with SQLite storage, published with the game, at the game's own
|
|
3
3
|
// api/db/. Nothing to sign up for and no keys. The page uses it through gamesfun.js (gf.db); this server's own code
|
|
4
4
|
// through dataOf(env).
|
|
5
5
|
//
|
|
6
|
-
// GET api/db/<collection>?owner=me|<id>&limit=&
|
|
7
|
-
// GET api/db/<collection>/<id>
|
|
8
|
-
// PUT api/db/<collection>/<id> {value}
|
|
9
|
-
// POST api/db/<collection> {value} a new id
|
|
10
|
-
// DELETE api/db/<collection>/<id> the
|
|
6
|
+
// GET api/db/<collection>?owner=me|server|<id>&limit=&after=&order=new|old → {ok, items, more, after}
|
|
7
|
+
// GET api/db/<collection>/<id>?owner=me|server|<id> → {ok, item}
|
|
8
|
+
// PUT api/db/<collection>/<id> {value} the signed-in player's own → {ok, item}
|
|
9
|
+
// POST api/db/<collection> {value} a new id, the player's own → {ok, item}
|
|
10
|
+
// DELETE api/db/<collection>/<id> the player's own → {ok, deleted}
|
|
11
11
|
//
|
|
12
|
-
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`), or "server"
|
|
12
|
+
// A document is {id, owner, value, updatedAt}: `owner` is the player's id (env.GF.verifyPlayer's `sub`), or "server"
|
|
13
|
+
// for what this server's own code writes. Every player has a space of their own in each collection: a document is
|
|
14
|
+
// found by its collection, its owner and its id, so two players can both have a "main", and no player can take an id
|
|
15
|
+
// in anyone else's space. A page names whose document it wants with `owner`; without it, the player's own (in a
|
|
16
|
+
// collection only the server writes: the server's, then the player's own, which the server wrote for them). A get by
|
|
17
|
+
// id alone that finds another player's document in a collection anyone reads answers 400 `owner`: name its owner.
|
|
18
|
+
// Lists come newest first (by when a document was last written), a page at a time: pass the answer's `after` back to
|
|
19
|
+
// get the next page. A document written between two pages moves to the front, so a listing taken while players write
|
|
20
|
+
// can show it twice or not at all: list what must be exact in one page (limit up to 100), or by a field of its own.
|
|
13
21
|
// Change who may read and write a collection in RULES below; that is the whole of its security, so read it once.
|
|
14
22
|
|
|
15
23
|
import { DurableObject } from 'cloudflare:workers';
|
|
16
24
|
|
|
17
25
|
/**
|
|
18
26
|
* Who may read and write each collection; '*' is every collection not named.
|
|
19
|
-
* read: '
|
|
27
|
+
* read: 'owner' (a player reads only their own documents) | 'anyone' (signed in or not: any player's, by its owner)
|
|
20
28
|
* write: 'owner' (a signed-in player writes only their own documents) | 'server' (only this server: dataOf(env))
|
|
21
29
|
*/
|
|
22
30
|
export const RULES = {
|
|
23
|
-
'*': { read: '
|
|
31
|
+
'*': { read: 'owner', write: 'owner' },
|
|
24
32
|
};
|
|
25
33
|
|
|
26
|
-
export const LIMITS = { valueBytes: 64 * 1024, docsPerPlayer: 1000, list: 100
|
|
34
|
+
export const LIMITS = { valueBytes: 64 * 1024, docsPerPlayer: 1000, list: 100 };
|
|
35
|
+
|
|
36
|
+
// ---- the documents' code (xcore add copies it, from here to the end mark, into server/gf-docs.js as well) ----
|
|
27
37
|
|
|
28
38
|
const NAME = /^[A-Za-z0-9_-]{1,64}$/;
|
|
29
39
|
const SERVER = 'server';
|
|
40
|
+
const OWNER_CHARS = 128;
|
|
30
41
|
const te = new TextEncoder();
|
|
31
42
|
|
|
32
43
|
const refuse = (status, code, why) => ({ ok: false, status, code, why });
|
|
44
|
+
const own = (o, k) => (Object.hasOwn(o, k) ? o[k] : undefined);
|
|
33
45
|
const ruleOf = (collection, rules) => ({
|
|
34
|
-
read: '
|
|
46
|
+
read: 'owner',
|
|
35
47
|
write: 'owner',
|
|
36
|
-
...(rules
|
|
37
|
-
...(rules
|
|
48
|
+
...(own(rules, '*') ?? {}),
|
|
49
|
+
...(own(rules, collection) ?? {}),
|
|
38
50
|
});
|
|
51
|
+
/** RULES, checked: a mistyped rule (read: 'everyone') would otherwise be read as something else. Throws in words. */
|
|
52
|
+
export function checkRules(rules) {
|
|
53
|
+
if (!rules || typeof rules !== 'object' || Array.isArray(rules))
|
|
54
|
+
throw new Error("RULES is an object: { '*': { read: 'owner', write: 'owner' } }.");
|
|
55
|
+
for (const [name, rule] of Object.entries(rules)) {
|
|
56
|
+
if (name !== '*' && !NAME.test(name))
|
|
57
|
+
throw new Error(`RULES: ${JSON.stringify(name)} is not a collection (1 to 64 letters, digits, - or _; or '*').`);
|
|
58
|
+
const ok =
|
|
59
|
+
rule &&
|
|
60
|
+
typeof rule === 'object' &&
|
|
61
|
+
!Array.isArray(rule) &&
|
|
62
|
+
Object.keys(rule).every(k => k === 'read' || k === 'write') &&
|
|
63
|
+
(rule.read === undefined || rule.read === 'owner' || rule.read === 'anyone') &&
|
|
64
|
+
(rule.write === undefined || rule.write === 'owner' || rule.write === 'server');
|
|
65
|
+
if (!ok) throw new Error(`RULES.${name} is { read: 'owner' | 'anyone', write: 'owner' | 'server' }.`);
|
|
66
|
+
}
|
|
67
|
+
return rules;
|
|
68
|
+
}
|
|
39
69
|
const row = r => r && { id: r.id, owner: r.owner, value: JSON.parse(r.value), updatedAt: r.updated };
|
|
40
70
|
|
|
71
|
+
// the list's place: the last document of a page, as text the page hands back (it never needs to read it)
|
|
72
|
+
const b64url = s =>
|
|
73
|
+
btoa(String.fromCharCode(...te.encode(s)))
|
|
74
|
+
.replace(/\+/g, '-')
|
|
75
|
+
.replace(/\//g, '_')
|
|
76
|
+
.replace(/=+$/, '');
|
|
77
|
+
const unb64url = s =>
|
|
78
|
+
new TextDecoder().decode(Uint8Array.from(atob(s.replace(/-/g, '+').replace(/_/g, '/')), c => c.charCodeAt(0)));
|
|
79
|
+
export const cursorOf = d => b64url(JSON.stringify([d.updatedAt, d.owner, d.id]));
|
|
80
|
+
function readCursor(text) {
|
|
81
|
+
try {
|
|
82
|
+
const [updated, owner, id] = JSON.parse(unb64url(String(text)));
|
|
83
|
+
if (Number.isSafeInteger(updated) && typeof owner === 'string' && typeof id === 'string')
|
|
84
|
+
return { updated, owner, id };
|
|
85
|
+
} catch {}
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The tables, made on first use. An older database.js kept one id per collection (whoever wrote it first owned it);
|
|
91
|
+
* its documents move to the new table as they are, each under its owner. Every step can run again: a move stopped
|
|
92
|
+
* half way is finished the next time.
|
|
93
|
+
*/
|
|
94
|
+
async function prepare(exec, table) {
|
|
95
|
+
const made = async name =>
|
|
96
|
+
(await exec(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`, name))[0]?.sql ?? null;
|
|
97
|
+
const create = name =>
|
|
98
|
+
exec(
|
|
99
|
+
`CREATE TABLE IF NOT EXISTS ${name} (collection TEXT NOT NULL, owner TEXT NOT NULL, id TEXT NOT NULL, value TEXT NOT NULL, updated INTEGER NOT NULL, PRIMARY KEY (collection, owner, id))`,
|
|
100
|
+
);
|
|
101
|
+
const now = await made(table);
|
|
102
|
+
const next = `${table}_v2`;
|
|
103
|
+
if (now && /PRIMARY KEY\s*\(\s*collection\s*,\s*id\s*\)/i.test(now)) {
|
|
104
|
+
await create(next);
|
|
105
|
+
await exec(
|
|
106
|
+
`INSERT OR IGNORE INTO ${next} (collection, owner, id, value, updated) SELECT collection, owner, id, value, updated FROM ${table}`,
|
|
107
|
+
);
|
|
108
|
+
await exec(`DROP TABLE ${table}`);
|
|
109
|
+
await exec(`ALTER TABLE ${next} RENAME TO ${table}`);
|
|
110
|
+
} else if (!now && (await made(next))) await exec(`ALTER TABLE ${next} RENAME TO ${table}`);
|
|
111
|
+
await create(table);
|
|
112
|
+
await exec(`CREATE INDEX IF NOT EXISTS ${table}_by_owner ON ${table} (collection, owner, updated, id)`);
|
|
113
|
+
await exec(`CREATE INDEX IF NOT EXISTS ${table}_by_time ON ${table} (collection, updated, owner, id)`);
|
|
114
|
+
await exec(`CREATE INDEX IF NOT EXISTS ${table}_by_id ON ${table} (collection, id)`);
|
|
115
|
+
}
|
|
116
|
+
|
|
41
117
|
/**
|
|
42
|
-
* The database over SQLite
|
|
43
|
-
* any SQLite with that shape). `who` is the player's id, '' for a guest,
|
|
118
|
+
* The database over SQLite. `exec(query, ...bindings)` answers the rows, or a promise of them (a Durable Object's
|
|
119
|
+
* ctx.storage.sql, D1, or any SQLite with that shape). Every call is async. `who` is the player's id, '' for a guest,
|
|
120
|
+
* or "server" for the server's own code. `opts.owner` names whose space a document is in: 'me', 'server' or a
|
|
121
|
+
* player's id.
|
|
44
122
|
*/
|
|
45
|
-
export function createData(
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
);
|
|
49
|
-
sql.exec('CREATE INDEX IF NOT EXISTS docs_owner ON docs (collection, owner, updated)');
|
|
50
|
-
sql.exec('CREATE INDEX IF NOT EXISTS docs_updated ON docs (collection, updated)');
|
|
51
|
-
const rows = (q, ...b) => [...sql.exec(q, ...b)];
|
|
123
|
+
export function createData(exec, { rules = RULES, limits = LIMITS, now = () => Date.now(), table = 'docs' } = {}) {
|
|
124
|
+
checkRules(rules);
|
|
125
|
+
const rows = async (q, ...b) => [...(await exec(q, ...b))];
|
|
126
|
+
const ready = prepare(rows, table);
|
|
52
127
|
const named = (collection, id) =>
|
|
53
128
|
!NAME.test(String(collection))
|
|
54
129
|
? refuse(400, 'bad', 'A collection is 1 to 64 letters, digits, - or _.')
|
|
55
130
|
: id !== undefined && !NAME.test(String(id))
|
|
56
131
|
? refuse(400, 'bad', 'An id is 1 to 64 letters, digits, - or _.')
|
|
57
132
|
: null;
|
|
58
|
-
|
|
133
|
+
/** Whose space `owner` names for `who`: {owner} or a refusal. Without one: the player's own, or the server's. */
|
|
134
|
+
const spaceOf = (rule, owner, who) => {
|
|
135
|
+
if (owner === undefined || owner === null || owner === '')
|
|
136
|
+
owner = who === SERVER || rule.write === 'server' ? SERVER : 'me';
|
|
137
|
+
if (owner === 'me') {
|
|
138
|
+
if (!who) return refuse(401, 'player', 'Sign in first, or name whose document it is (owner).');
|
|
139
|
+
return { owner: who };
|
|
140
|
+
}
|
|
141
|
+
if (typeof owner !== 'string' || owner.length > OWNER_CHARS)
|
|
142
|
+
return refuse(400, 'bad', `An owner is "me", "server" or a player's id (at most ${OWNER_CHARS} characters).`);
|
|
143
|
+
return { owner };
|
|
144
|
+
};
|
|
145
|
+
/** May `who` write in `owner`'s space of this collection? null, or the refusal. */
|
|
146
|
+
const canWrite = (rule, owner, who) =>
|
|
59
147
|
who === SERVER
|
|
60
148
|
? null
|
|
61
149
|
: rule.write === 'server'
|
|
62
150
|
? refuse(403, 'server-only', 'Only the game itself writes here.')
|
|
63
|
-
: who
|
|
64
|
-
?
|
|
65
|
-
:
|
|
151
|
+
: !who
|
|
152
|
+
? refuse(401, 'player', 'Sign in first.')
|
|
153
|
+
: owner !== who
|
|
154
|
+
? refuse(403, 'not-yours', 'A player writes only their own documents.')
|
|
155
|
+
: null;
|
|
156
|
+
const canRead = (rule, owner, who) => who === SERVER || rule.read === 'anyone' || owner === who;
|
|
66
157
|
|
|
67
158
|
const data = {
|
|
68
|
-
get(collection, id, who = '') {
|
|
159
|
+
async get(collection, id, opts = {}, who = '') {
|
|
160
|
+
await ready;
|
|
69
161
|
const bad = named(collection, id);
|
|
70
162
|
if (bad) return bad;
|
|
71
|
-
const item = row(rows('SELECT * FROM docs WHERE collection = ? AND id = ?', collection, id)[0]);
|
|
72
|
-
if (!item) return refuse(404, 'unknown', 'No such document.');
|
|
73
163
|
const rule = ruleOf(collection, rules);
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
164
|
+
const space = spaceOf(rule, opts?.owner, who);
|
|
165
|
+
if (!space.owner) return space;
|
|
166
|
+
const byIdAlone = opts?.owner === undefined || opts?.owner === null || opts?.owner === '';
|
|
167
|
+
// where to look: the space named; by id alone in a collection only the server writes, the server's and then the
|
|
168
|
+
// player's own (what the server wrote for them)
|
|
169
|
+
const spaces = [space.owner];
|
|
170
|
+
if (byIdAlone && rule.write === 'server' && who && who !== SERVER) spaces.push(who);
|
|
171
|
+
for (const owner of spaces) {
|
|
172
|
+
// read: 'owner': another player's document is not there, not even that it exists
|
|
173
|
+
if (!canRead(rule, owner, who)) continue;
|
|
174
|
+
const item = row(
|
|
175
|
+
(
|
|
176
|
+
await rows(`SELECT * FROM ${table} WHERE collection = ? AND owner = ? AND id = ?`, collection, owner, id)
|
|
177
|
+
)[0],
|
|
178
|
+
);
|
|
179
|
+
if (item) return { ok: true, item };
|
|
180
|
+
}
|
|
181
|
+
// by id alone, in a collection anyone reads, another player's document (how ids worked before each player had a
|
|
182
|
+
// space of their own): said, with what to send instead
|
|
183
|
+
if (byIdAlone && rule.read === 'anyone' && who !== SERVER) {
|
|
184
|
+
const other = (
|
|
185
|
+
await rows(`SELECT owner FROM ${table} WHERE collection = ? AND id = ? LIMIT 1`, collection, id)
|
|
186
|
+
)[0];
|
|
187
|
+
if (other)
|
|
188
|
+
return refuse(
|
|
189
|
+
400,
|
|
190
|
+
'owner',
|
|
191
|
+
`${collection}/${id} is not in your own space: every player has a space of their own, so name whose document it is with {owner} (gf.db.get('${collection}', '${id}', {owner}), GET api/db/${collection}/${id}?owner=<their id>). A document's owner is in it, and in each item of a list.`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
return refuse(404, 'unknown', 'No such document.');
|
|
77
195
|
},
|
|
78
|
-
put(collection, id, value, who = '') {
|
|
196
|
+
async put(collection, id, value, opts = {}, who = '') {
|
|
197
|
+
await ready;
|
|
79
198
|
const bad = named(collection, id);
|
|
80
199
|
if (bad) return bad;
|
|
81
200
|
const rule = ruleOf(collection, rules);
|
|
82
|
-
const
|
|
201
|
+
const space = spaceOf(rule, opts?.owner, who);
|
|
202
|
+
if (!space.owner) return space;
|
|
203
|
+
const denied = canWrite(rule, space.owner, who);
|
|
83
204
|
if (denied) return denied;
|
|
84
205
|
if (value === undefined) return refuse(400, 'bad', 'A document needs a value (any JSON).');
|
|
85
206
|
const text = JSON.stringify(value);
|
|
86
207
|
if (te.encode(text).byteLength > limits.valueBytes)
|
|
87
208
|
return refuse(413, 'too-big', `A document may be ${limits.valueBytes / 1024} KB.`);
|
|
88
|
-
const was = rows('SELECT owner FROM docs WHERE collection = ? AND id = ?', collection, id)[0];
|
|
89
|
-
if (was && who !== SERVER && was.owner !== who)
|
|
90
|
-
return refuse(403, 'not-yours', 'Another player owns this document.');
|
|
91
|
-
if (!was && who !== SERVER) {
|
|
92
|
-
const n = rows('SELECT count(*) AS n FROM docs WHERE collection = ? AND owner = ?', collection, who)[0].n;
|
|
93
|
-
if (n >= limits.docsPerPlayer)
|
|
94
|
-
return refuse(409, 'full', `A player may keep ${limits.docsPerPlayer} documents in ${collection}.`);
|
|
95
|
-
}
|
|
96
|
-
const owner = was ? was.owner : who;
|
|
97
209
|
const t = now();
|
|
98
|
-
|
|
99
|
-
|
|
210
|
+
// one statement, so two calls at once cannot both pass the count: a document already there is replaced; a new
|
|
211
|
+
// one only while its owner has room (the server's own space has no cap)
|
|
212
|
+
const cap = space.owner === SERVER ? Number.MAX_SAFE_INTEGER : limits.docsPerPlayer;
|
|
213
|
+
const done = await rows(
|
|
214
|
+
`INSERT INTO ${table} (collection, owner, id, value, updated) SELECT ?, ?, ?, ?, ? WHERE EXISTS (SELECT 1 FROM ${table} WHERE collection = ? AND owner = ? AND id = ?) OR (SELECT count(*) FROM ${table} WHERE collection = ? AND owner = ?) < ? ON CONFLICT (collection, owner, id) DO UPDATE SET value = excluded.value, updated = excluded.updated RETURNING id`,
|
|
100
215
|
collection,
|
|
216
|
+
space.owner,
|
|
101
217
|
id,
|
|
102
|
-
owner,
|
|
103
218
|
text,
|
|
104
219
|
t,
|
|
220
|
+
collection,
|
|
221
|
+
space.owner,
|
|
222
|
+
id,
|
|
223
|
+
collection,
|
|
224
|
+
space.owner,
|
|
225
|
+
cap,
|
|
105
226
|
);
|
|
106
|
-
|
|
227
|
+
if (!done.length)
|
|
228
|
+
return refuse(409, 'full', `A player may keep ${limits.docsPerPlayer} documents in ${collection}.`);
|
|
229
|
+
return { ok: true, item: { id, owner: space.owner, value: JSON.parse(text), updatedAt: t } };
|
|
107
230
|
},
|
|
108
|
-
add(collection, value, who = '') {
|
|
109
|
-
return data.put(collection, crypto.randomUUID().replaceAll('-', '').slice(0, 20), value, who);
|
|
231
|
+
async add(collection, value, opts = {}, who = '') {
|
|
232
|
+
return data.put(collection, crypto.randomUUID().replaceAll('-', '').slice(0, 20), value, opts, who);
|
|
110
233
|
},
|
|
111
|
-
list(collection, { owner = '', limit = 50,
|
|
234
|
+
async list(collection, { owner = '', limit = 50, after = '', order = 'new', offset } = {}, who = '') {
|
|
235
|
+
await ready;
|
|
112
236
|
const bad = named(collection);
|
|
113
237
|
if (bad) return bad;
|
|
238
|
+
if (offset !== undefined && offset !== '' && Number(offset) !== 0)
|
|
239
|
+
return refuse(400, 'bad', "offset is gone: page with after (the last answer's after).");
|
|
114
240
|
const rule = ruleOf(collection, rules);
|
|
115
|
-
let of =
|
|
116
|
-
if (owner
|
|
117
|
-
|
|
241
|
+
let of = '';
|
|
242
|
+
if (owner) {
|
|
243
|
+
const space = spaceOf(rule, owner, who);
|
|
244
|
+
if (!space.owner) return space;
|
|
245
|
+
of = space.owner;
|
|
246
|
+
}
|
|
247
|
+
// anything but read: 'anyone' is the player's own only (fail closed: a rule this code does not know shows nothing)
|
|
248
|
+
if (rule.read !== 'anyone' && who !== SERVER) {
|
|
118
249
|
if (!who) return refuse(401, 'player', 'Sign in first.');
|
|
119
250
|
of = who; // a player lists only their own here
|
|
120
251
|
}
|
|
121
252
|
const n = Math.max(1, Math.min(limits.list, Number(limit) | 0 || 50));
|
|
122
|
-
const
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
return { ok: true, items:
|
|
253
|
+
const from = after ? readCursor(after) : null;
|
|
254
|
+
if (after && !from) return refuse(400, 'bad', "after is the last answer's after, as it came.");
|
|
255
|
+
const old = order === 'old';
|
|
256
|
+
const dir = old ? 'ASC' : 'DESC';
|
|
257
|
+
const where = [`collection = ?`];
|
|
258
|
+
const args = [collection];
|
|
259
|
+
if (of) (where.push('owner = ?'), args.push(of));
|
|
260
|
+
if (from)
|
|
261
|
+
(where.push(`(updated, owner, id) ${old ? '>' : '<'} (?, ?, ?)`), args.push(from.updated, from.owner, from.id));
|
|
262
|
+
const found = await rows(
|
|
263
|
+
`SELECT * FROM ${table} WHERE ${where.join(' AND ')} ORDER BY updated ${dir}, owner ${dir}, id ${dir} LIMIT ?`,
|
|
264
|
+
...args,
|
|
265
|
+
n + 1,
|
|
266
|
+
);
|
|
267
|
+
const items = found.slice(0, n).map(row);
|
|
268
|
+
const more = found.length > n;
|
|
269
|
+
return { ok: true, items, more, after: more ? cursorOf(items.at(-1)) : null };
|
|
139
270
|
},
|
|
140
|
-
delete(collection, id, who = '') {
|
|
271
|
+
async delete(collection, id, opts = {}, who = '') {
|
|
272
|
+
await ready;
|
|
141
273
|
const bad = named(collection, id);
|
|
142
274
|
if (bad) return bad;
|
|
143
275
|
const rule = ruleOf(collection, rules);
|
|
144
|
-
const
|
|
276
|
+
const space = spaceOf(rule, opts?.owner, who);
|
|
277
|
+
if (!space.owner) return space;
|
|
278
|
+
const denied = canWrite(rule, space.owner, who);
|
|
145
279
|
if (denied) return denied;
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
280
|
+
const gone = await rows(
|
|
281
|
+
`DELETE FROM ${table} WHERE collection = ? AND owner = ? AND id = ? RETURNING id`,
|
|
282
|
+
collection,
|
|
283
|
+
space.owner,
|
|
284
|
+
id,
|
|
285
|
+
);
|
|
286
|
+
return { ok: true, deleted: gone.length > 0 };
|
|
151
287
|
},
|
|
152
288
|
};
|
|
289
|
+
data.ready = ready;
|
|
153
290
|
return data;
|
|
154
291
|
}
|
|
155
292
|
|
|
156
|
-
/**
|
|
157
|
-
export
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
return this.data[op](...args);
|
|
166
|
-
}
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
const stubOf = env => env.GAME_DATA.get(env.GAME_DATA.idFromName('main'));
|
|
170
|
-
|
|
171
|
-
/** This server's own access, past the players' rules (owner "server"): await dataOf(env).put('news', 'today', {…}). */
|
|
172
|
-
export function dataOf(env) {
|
|
173
|
-
const stub = stubOf(env);
|
|
174
|
-
const as =
|
|
175
|
-
op =>
|
|
176
|
-
(...args) =>
|
|
177
|
-
stub.run(op, op === 'list' ? [args[0], args[1] ?? {}, SERVER] : [...args, SERVER]);
|
|
178
|
-
return { get: as('get'), put: as('put'), add: as('add'), list: as('list'), delete: as('delete') };
|
|
293
|
+
/** dataOf's calls over any `run(op, args)` (the Durable Object's, or the stock server's D1). */
|
|
294
|
+
export function serverData(run) {
|
|
295
|
+
return {
|
|
296
|
+
get: (collection, id, opts = {}) => run('get', [collection, id, opts, SERVER]),
|
|
297
|
+
put: (collection, id, value, opts = {}) => run('put', [collection, id, value, opts, SERVER]),
|
|
298
|
+
add: (collection, value, opts = {}) => run('add', [collection, value, opts, SERVER]),
|
|
299
|
+
list: (collection, opts = {}) => run('list', [collection, opts, SERVER]),
|
|
300
|
+
delete: (collection, id, opts = {}) => run('delete', [collection, id, opts, SERVER]),
|
|
301
|
+
};
|
|
179
302
|
}
|
|
180
303
|
|
|
181
304
|
const reply = (a, status = a.ok ? 200 : a.status) => {
|
|
@@ -183,8 +306,11 @@ const reply = (a, status = a.ok ? 200 : a.status) => {
|
|
|
183
306
|
return Response.json(body, { status, headers: { 'cache-control': 'no-store' } });
|
|
184
307
|
};
|
|
185
308
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
309
|
+
/**
|
|
310
|
+
* The page's calls to api/db/…, answered by `run(op, args)` (createData's calls). → the Response, or null for any
|
|
311
|
+
* other path, so the server's own routes go on.
|
|
312
|
+
*/
|
|
313
|
+
export async function routeDatabase(request, env, run) {
|
|
188
314
|
const url = new URL(request.url);
|
|
189
315
|
const m = /\/api\/db\/([^/]+)(?:\/([^/]+))?\/?$/.exec(url.pathname);
|
|
190
316
|
if (!m) return null;
|
|
@@ -195,7 +321,7 @@ export async function handleDatabase(request, env) {
|
|
|
195
321
|
} catch {
|
|
196
322
|
return reply(refuse(400, 'bad', 'Not a path.'));
|
|
197
323
|
}
|
|
198
|
-
// who is asking: the sign-in token the page sends (gamesfun.js does), checked by
|
|
324
|
+
// who is asking: the sign-in token the page sends (gamesfun.js does), checked by xgames.fun
|
|
199
325
|
const bearer = /^Bearer\s+(\S+)$/i.exec(request.headers.get('authorization') ?? '')?.[1];
|
|
200
326
|
let who = '';
|
|
201
327
|
if (bearer) {
|
|
@@ -204,6 +330,9 @@ export async function handleDatabase(request, env) {
|
|
|
204
330
|
return reply(
|
|
205
331
|
refuse(401, 'player', `Sign in again: ${String(v?.why ?? 'not a player token').replace(/\.$/, '')}.`),
|
|
206
332
|
);
|
|
333
|
+
// "server" is the server's own space: no player is it
|
|
334
|
+
if (typeof v.sub !== 'string' || !v.sub || v.sub === SERVER)
|
|
335
|
+
return reply(refuse(401, 'player', 'Sign in again: not a player.'));
|
|
207
336
|
who = v.sub;
|
|
208
337
|
}
|
|
209
338
|
const method = request.method.toUpperCase();
|
|
@@ -218,12 +347,45 @@ export async function handleDatabase(request, env) {
|
|
|
218
347
|
return reply(refuse(400, 'bad', 'The body is not JSON.'));
|
|
219
348
|
}
|
|
220
349
|
}
|
|
221
|
-
const stub = stubOf(env);
|
|
222
350
|
const q = Object.fromEntries(url.searchParams);
|
|
223
|
-
|
|
224
|
-
if (id === undefined && method === '
|
|
225
|
-
if (id
|
|
226
|
-
if (id !== undefined && method === '
|
|
227
|
-
if (id !== undefined && method === '
|
|
351
|
+
const opts = q.owner === undefined ? {} : { owner: q.owner };
|
|
352
|
+
if (id === undefined && method === 'GET') return reply(await run('list', [collection, q, who]));
|
|
353
|
+
if (id === undefined && method === 'POST') return reply(await run('add', [collection, body.value, opts, who]));
|
|
354
|
+
if (id !== undefined && method === 'GET') return reply(await run('get', [collection, id, opts, who]));
|
|
355
|
+
if (id !== undefined && method === 'PUT') return reply(await run('put', [collection, id, body.value, opts, who]));
|
|
356
|
+
if (id !== undefined && method === 'DELETE') return reply(await run('delete', [collection, id, opts, who]));
|
|
228
357
|
return reply(refuse(405, 'method', `${method} is not a database call here.`));
|
|
229
358
|
}
|
|
359
|
+
|
|
360
|
+
// ---- end of the documents' code ----
|
|
361
|
+
|
|
362
|
+
/** The Durable Object that holds the database (gamesfun.json → server.objects: {class: "GameData", binding: "GAME_DATA"}). */
|
|
363
|
+
export class GameData extends DurableObject {
|
|
364
|
+
constructor(ctx, env) {
|
|
365
|
+
super(ctx, env);
|
|
366
|
+
this.data = createData((q, ...b) => ctx.storage.sql.exec(q, ...b));
|
|
367
|
+
// the tables are ready (an older one moved) before the first call
|
|
368
|
+
ctx.blockConcurrencyWhile?.(() => this.data.ready);
|
|
369
|
+
}
|
|
370
|
+
/** One call, over RPC: op is get | put | add | list | delete, args as createData's. */
|
|
371
|
+
run(op, args) {
|
|
372
|
+
if (!['get', 'put', 'add', 'list', 'delete'].includes(op)) return refuse(400, 'bad', `No such call: ${op}.`);
|
|
373
|
+
return this.data[op](...args);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const stubOf = env => env.GAME_DATA.get(env.GAME_DATA.idFromName('main'));
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* This server's own access, past the players' rules. Without an owner it reads and writes the server's own space
|
|
381
|
+
* (owner "server"): await dataOf(env).put('news', 'today', {…}). With one, a player's: dataOf(env).get('profiles',
|
|
382
|
+
* 'main', {owner: sub}). A list without an owner is everyone's.
|
|
383
|
+
*/
|
|
384
|
+
export function dataOf(env) {
|
|
385
|
+
return serverData((op, args) => stubOf(env).run(op, args));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** The page's calls to api/db/…, to the one Durable Object; null for any other path. */
|
|
389
|
+
export function handleDatabase(request, env) {
|
|
390
|
+
return routeDatabase(request, env, (op, args) => stubOf(env).run(op, args));
|
|
391
|
+
}
|