@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,8 +1,8 @@
1
- # GamesFun: sign-in, saves, leaderboards and the game's own database on games.fun, for a Godot 4 Web export.
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 games.fun's page client (gamesfun.js, carried inside this file) into the page and calls it. Every call
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: games.fun pages are not cross-origin isolated.
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 games.fun (xcore add multiplayer)
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": "games.fun runs in a Web export only", "status": 0}
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
- func db_get(collection: String, id: String):
172
- return await _run("db.get", [collection, id])
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, "offset": 0, "order": "new" | "old"}
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 (games.fun's room links and their X cards pass ?room=CODE), or "".
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 (games.fun's play page once published; its X card shows the 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
- # games.fun's page client (lib/client/gamesfun.js), as base64
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: a games.fun room as Godot's MultiplayerPeer, so a game's high-level multiplayer (RPCs,
2
- # MultiplayerSpawner, MultiplayerSynchronizer, multiplayer.peer_connected …) runs over games.fun as it runs over ENet.
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 games.fun, written into server/ by `xcore add database`. It is part of the game's server
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=&offset=&order=new|old → {ok, items, more}
7
- // GET api/db/<collection>/<id> → {ok, item}
8
- // PUT api/db/<collection>/<id> {value} a signed-in player's own → {ok, item}
9
- // POST api/db/<collection> {value} a new id → {ok, item}
10
- // DELETE api/db/<collection>/<id> the owner's → {ok, deleted}
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: 'anyone' (signed in or not) | 'owner' (a player reads only their own documents)
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: 'anyone', write: 'owner' },
31
+ '*': { read: 'owner', write: 'owner' },
24
32
  };
25
33
 
26
- export const LIMITS = { valueBytes: 64 * 1024, docsPerPlayer: 1000, list: 100, offset: 10000 };
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: 'anyone',
46
+ read: 'owner',
35
47
  write: 'owner',
36
- ...(rules['*'] ?? {}),
37
- ...(rules[collection] ?? {}),
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: `sql.exec(query, ...bindings)` answers the rows (a Durable Object's ctx.storage.sql, or
43
- * any SQLite with that shape). `who` is the player's id, '' for a guest, or "server" for the server's own code.
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(sql, { rules = RULES, limits = LIMITS, now = () => Date.now() } = {}) {
46
- sql.exec(
47
- 'CREATE TABLE IF NOT EXISTS docs (collection TEXT NOT NULL, id TEXT NOT NULL, owner TEXT NOT NULL, value TEXT NOT NULL, updated INTEGER NOT NULL, PRIMARY KEY (collection, id))',
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
- const canWrite = (rule, who) =>
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
- ? null
65
- : refuse(401, 'player', 'Sign in first.');
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
- if (rule.read === 'owner' && who !== SERVER && item.owner !== who)
75
- return refuse(404, 'unknown', 'No such document.');
76
- return { ok: true, item };
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 denied = canWrite(rule, who);
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
- sql.exec(
99
- 'INSERT INTO docs (collection, id, owner, value, updated) VALUES (?, ?, ?, ?, ?) ON CONFLICT (collection, id) DO UPDATE SET value = excluded.value, updated = excluded.updated',
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
- return { ok: true, item: { id, owner, value: JSON.parse(text), updatedAt: t } };
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, offset = 0, order = 'new' } = {}, who = '') {
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 = owner === 'me' ? who : String(owner || '');
116
- if (owner === 'me' && !who) return refuse(401, 'player', 'Sign in first.');
117
- if (rule.read === 'owner' && who !== SERVER) {
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 skip = Math.max(0, Math.min(limits.offset, Number(offset) | 0));
123
- const dir = order === 'old' ? 'ASC' : 'DESC';
124
- const found = of
125
- ? rows(
126
- `SELECT * FROM docs WHERE collection = ? AND owner = ? ORDER BY updated ${dir}, id LIMIT ? OFFSET ?`,
127
- collection,
128
- of,
129
- n + 1,
130
- skip,
131
- )
132
- : rows(
133
- `SELECT * FROM docs WHERE collection = ? ORDER BY updated ${dir}, id LIMIT ? OFFSET ?`,
134
- collection,
135
- n + 1,
136
- skip,
137
- );
138
- return { ok: true, items: found.slice(0, n).map(row), more: found.length > n };
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 denied = canWrite(rule, who);
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 was = rows('SELECT owner FROM docs WHERE collection = ? AND id = ?', collection, id)[0];
147
- if (!was) return { ok: true, deleted: false };
148
- if (who !== SERVER && was.owner !== who) return refuse(403, 'not-yours', 'Another player owns this document.');
149
- sql.exec('DELETE FROM docs WHERE collection = ? AND id = ?', collection, id);
150
- return { ok: true, deleted: true };
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
- /** The Durable Object that holds the database (gamesfun.json → server.objects: {class: "GameData", binding: "GAME_DATA"}). */
157
- export class GameData extends DurableObject {
158
- constructor(ctx, env) {
159
- super(ctx, env);
160
- this.data = createData(ctx.storage.sql);
161
- }
162
- /** One call, over RPC: op is get | put | add | list | delete, args as createData's. */
163
- run(op, args) {
164
- if (!Object.hasOwn(this.data, op)) return refuse(400, 'bad', `No such call: ${op}.`);
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
- /** The page's calls to api/db/…; null for any other path, so the server's own routes go on. */
187
- export async function handleDatabase(request, env) {
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 games.fun
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
- if (id === undefined && method === 'GET') return reply(await stub.run('list', [collection, q, who]));
224
- if (id === undefined && method === 'POST') return reply(await stub.run('add', [collection, body.value, who]));
225
- if (id !== undefined && method === 'GET') return reply(await stub.run('get', [collection, id, who]));
226
- if (id !== undefined && method === 'PUT') return reply(await stub.run('put', [collection, id, body.value, who]));
227
- if (id !== undefined && method === 'DELETE') return reply(await stub.run('delete', [collection, id, who]));
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
+ }