@homie-rocks/studio 0.53.0 → 0.55.0

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 (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
@@ -0,0 +1,445 @@
1
+ import {studioTools,directoryOf} from './tool-registry.mjs';
2
+ export {useTools,getStudioTools} from './tool-registry.mjs';
3
+ import {json,finish} from './responses.mjs';
4
+ /** Shared catalogue, assets, and room presentation helpers. */
5
+ import {catalogue,builtPosts} from './catalogue.mjs';
6
+
7
+ import {appGraphicsFallback} from './app-graphics.mjs';
8
+ import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
9
+
10
+ import { directoryUrl } from './directory-choice.mjs';
11
+ import {APP_STORAGE_JS} from './app-frame.mjs';
12
+
13
+ import { appRole } from './app-format.mjs';
14
+ import { playerEnabled, EMBED_GAME_JS } from './embed.mjs';
15
+ import { versionOf } from './room-protocol.mjs';
16
+
17
+ import { ROOM_ID, frameAncestors, embedAncestors } from './page-policy.mjs';
18
+ import { PUBLIC_SERVER, memberCounts, memberOf, roomServer, serverView, serversOf } from './servers.mjs';
19
+
20
+ import { chatOf, chatRowsOf } from './chat-store.mjs';
21
+
22
+ import { paramsFrom, seatsOf } from './seats.mjs';
23
+ import { mediaArt, notFoundPage, roomView, watchOf } from './site.mjs';
24
+ import { count, isQa, playedThisWeek, today } from './stats.mjs';
25
+ import { players as playerAccounts } from './players.mjs';
26
+ import { appAssetTicket, holders, launchOf, settingsOf } from './office.mjs';
27
+
28
+ import { STUDIO_VERSION_TAG } from './version.mjs';
29
+ import { readiness, shellShop, shopOf } from './shop.mjs';
30
+
31
+ import {PART_FILES} from './asset-paths.mjs';
32
+ import {defaultPlayers} from './player-registry.mjs';
33
+ defaultPlayers(playerAccounts);
34
+
35
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
36
+
37
+ const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
38
+
39
+ import {GAME_FILES} from './asset-paths.mjs';
40
+
41
+ const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
42
+
43
+ const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
44
+
45
+ const TICKET_TEXT = /^[a-z0-9]{6,12}\.[A-Za-z0-9_~-]{1,140}\.[A-Za-z0-9_-]{32}$/;
46
+
47
+
48
+
49
+ async function sellingShop(env, cat) {
50
+ const shop = shopOf(cat);
51
+ if (!shop || shop.broken) return null;
52
+ try { return (await readiness(env, shop)).ready ? shop : null; } catch { return null; }
53
+ }
54
+
55
+ async function availableShellShop(env, cat, game, options) {
56
+ // The shell can show a disabled catalog before payment setup is complete.
57
+ // Checkout readiness is enforced by the shop API, separately from visibility.
58
+ const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
59
+ const shop = shopOf(cat);
60
+ // Free services must retain their bridge in a mixed free/paid catalogue.
61
+ // Paid readiness, policy and atomic booking capacity remain API decisions.
62
+ const free = shop?.open && !shop.broken && shop.items.some(i => i.price === 0 && !i.interval && (!i.game || i.game === game));
63
+ return (preview && !shop?.broken || free || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
64
+ }
65
+
66
+ async function postsOf(env, origin) {
67
+ const baked = await builtPosts();
68
+ if (baked) return baked;
69
+ try {
70
+ const res = await env.ASSETS.fetch(new Request(`${origin}/_site/posts.json`));
71
+ return res.ok ? ((await res.json()).posts ?? []) : [];
72
+ } catch { return []; }
73
+ }
74
+
75
+ const CLAIM = /^[a-f0-9]{16,128}$/;
76
+
77
+ const claimed = new Map();
78
+
79
+ const claimMissed = new Map();
80
+
81
+ async function storedClaim(env, origin) {
82
+ if (!env.DB) return null;
83
+ try {
84
+ const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2, ?3)').bind(`homie_claim:${origin}`, 'homie_claim', `homie_repo:${origin}`).all()).results ?? [];
85
+ const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
86
+ return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
87
+ } catch { return null; }
88
+ }
89
+
90
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
91
+
92
+ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
93
+
94
+ const repoSaid = new Map();
95
+
96
+ export {directoryOf} from './tool-registry.mjs';
97
+
98
+ async function claimOf(env, cat, url) {
99
+ const origin = url.origin;
100
+ const repo = repoOf(env);
101
+ if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
102
+ const kept = await storedClaim(env, origin);
103
+ if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
104
+ if (kept?.older) return kept.older;
105
+ const directory = directoryOf(cat);
106
+ const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
107
+ const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
108
+ && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
109
+ if (may) {
110
+ try {
111
+ // Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
112
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
113
+ headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
114
+ });
115
+ const body = await res.json().catch(() => null);
116
+ if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
117
+ await env.DB.batch([
118
+ env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
119
+ ...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
120
+ ]);
121
+ claimed.set(origin, body.claim);
122
+ if (repo) repoSaid.set(origin, repo);
123
+ return body.claim;
124
+ }
125
+ } catch { /* the directory is optional for a live site */ }
126
+ claimMissed.set(origin, Date.now());
127
+ }
128
+ return kept?.own ?? kept?.older ?? null;
129
+ }
130
+
131
+ function named(cat, env) {
132
+ if (!cat?.studio?.template || !env.STUDIO_NAME) return cat;
133
+ return { ...cat, studio: { ...cat.studio, name: String(env.STUDIO_NAME).slice(0, 60) } };
134
+ }
135
+
136
+ async function roomsOf(env, games, { servers = null } = {}) {
137
+ const rooms = [];
138
+ const live = {};
139
+ // Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
140
+ const chatRows = await chatRowsOf(env);
141
+ const summaries=null;
142
+ await Promise.all(games.map(async (g) => {
143
+ let list = [];
144
+ if(summaries)list=summaries.get(g.id)??[];
145
+ else try { list = (await (await env.LOBBY.get(env.LOBBY.idFromName(g.id)).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
146
+ const busy = list.filter((r) => (r.players || 0) > 0);
147
+ live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
148
+ const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
149
+ for (const r of busy) {
150
+ const sid = r.server ?? roomServer(r.name);
151
+ const srv = sid === 'public' ? (known.find((x) => x.id === 'public') ?? PUBLIC_SERVER) : known.find((x) => x.id === sid);
152
+ if (!srv || (sid !== 'public' && (!srv.listed || srv.state !== 'open' || srv.door !== 'open'))) continue;
153
+ const chat = chatOf(g, srv, chatRows);
154
+ rooms.push(roomView(g, r, seatsOf(g), srv, { chat: chat.mode !== 'off' && chat.hub }));
155
+ }
156
+ }));
157
+ rooms.sort((a, b) => b.players - a.players || a.game.localeCompare(b.game) || String(a.room).localeCompare(String(b.room)));
158
+ return { rooms, live };
159
+ }
160
+
161
+ const MEDIA_SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
162
+
163
+ function parseRange(header, size) {
164
+ const m = /^bytes=(\d*)-(\d*)$/.exec(String(header ?? '').trim());
165
+ if (!m || (m[1] === '' && m[2] === '')) return null;
166
+ let start; let end;
167
+ if (m[1] === '') { const n = Number(m[2]); start = Math.max(0, size - n); end = size - 1; }
168
+ else { start = Number(m[1]); end = m[2] === '' ? size - 1 : Math.min(Number(m[2]), size - 1); }
169
+ if (!(start <= end) || start >= size) return { unsatisfiable: true };
170
+ return { start, end };
171
+ }
172
+
173
+ async function assetWithRange(request, env, url) {
174
+ const res = await env.ASSETS.fetch(new Request(url.toString(), { method: 'GET' }));
175
+ const range = request.headers.get('range');
176
+ if (!res.ok || !range || res.status === 206) return res;
177
+ const body = new Uint8Array(await res.arrayBuffer());
178
+ const r = parseRange(range, body.byteLength);
179
+ const headers = new Headers(res.headers);
180
+ headers.set('accept-ranges', 'bytes');
181
+ if (!r) return new Response(body, { status: 200, headers });
182
+ if (r.unsatisfiable) { headers.set('content-range', `bytes */${body.byteLength}`); return new Response(null, { status: 416, headers }); }
183
+ headers.set('content-range', `bytes ${r.start}-${r.end}/${body.byteLength}`);
184
+ headers.set('content-length', String(r.end - r.start + 1));
185
+ return new Response(body.subarray(r.start, r.end + 1), { status: 206, headers });
186
+ }
187
+
188
+ function etagMatches(header, etag) {
189
+ if (!header || !etag) return false;
190
+ const bare = (t) => String(t).trim().replace(/^W\//, '');
191
+ return header.split(',').some((t) => t.trim() === '*' || bare(t) === bare(etag));
192
+ }
193
+
194
+ async function mediaObject(request, env, key, { cache = 'public, max-age=3600', type = null } = {}) {
195
+ if (key.startsWith('paid-parts/')) return new Response('not found', { status: 404 });
196
+ const range = request.headers.get('range');
197
+ const inm = request.headers.get('if-none-match');
198
+ const headOnly = request.method === 'HEAD';
199
+ let head = null;
200
+ if (range || inm || headOnly) {
201
+ head = await env.MEDIA.head(key);
202
+ if (!head) return new Response('not found', { status: 404 });
203
+ }
204
+ const base = (obj) => {
205
+ const headers = new Headers();
206
+ obj.writeHttpMetadata?.(headers);
207
+ if (!headers.get('content-type') && type) headers.set('content-type', type);
208
+ if (obj.httpEtag) headers.set('etag', obj.httpEtag);
209
+ if (obj.uploaded) headers.set('last-modified', new Date(obj.uploaded).toUTCString());
210
+ headers.set('cache-control', cache);
211
+ headers.set('accept-ranges', 'bytes');
212
+ return headers;
213
+ };
214
+ if (head && inm && etagMatches(inm, head.httpEtag)) return new Response(null, { status: 304, headers: base(head) });
215
+ const r = head && range ? parseRange(range, head.size) : null;
216
+ if (r?.unsatisfiable) return new Response(null, { status: 416, headers: { 'content-range': `bytes */${head.size}`, 'accept-ranges': 'bytes' } });
217
+ if (headOnly) {
218
+ const headers = base(head);
219
+ headers.set('content-length', String(head.size));
220
+ return new Response(null, { headers });
221
+ }
222
+ const obj = await env.MEDIA.get(key, r ? { range: { offset: r.start, length: r.end - r.start + 1 } } : undefined);
223
+ if (!obj) return new Response('not found', { status: 404 });
224
+ const headers = base(obj);
225
+ if (r) {
226
+ headers.set('content-range', `bytes ${r.start}-${r.end}/${head.size}`);
227
+ headers.set('content-length', String(r.end - r.start + 1));
228
+ return new Response(obj.body, { status: 206, headers });
229
+ }
230
+ headers.set('content-length', String(obj.size));
231
+ return new Response(obj.body, { headers });
232
+ }
233
+
234
+ const ASSET_CACHE = 'public, max-age=0, must-revalidate';
235
+
236
+ async function heldInR2(env, getCat, path) {
237
+ if (!env.MEDIA) return null;
238
+ const cat = await getCat();
239
+ let want;
240
+ try { want = decodeURIComponent(path); } catch { return null; }
241
+ for (const e of [...(cat.songs ?? []), ...(cat.videos ?? [])]) {
242
+ for (const f of e.files ?? []) {
243
+ if (typeof f.r2 !== 'string' || !f.r2 || (f.r2.startsWith('players/') || f.r2.startsWith('paid-parts/')) || typeof f.url !== 'string') continue;
244
+ let at;
245
+ try { at = decodeURIComponent(f.url); } catch { continue; }
246
+ if (at === want) return f;
247
+ }
248
+ }
249
+ return null;
250
+ }
251
+
252
+ function mediaRow(e, origin, kind, cat) {
253
+ const abs = (u) => (u && u.startsWith('/') ? `${origin}${u}` : u ?? null);
254
+ const main = (e.files ?? []).find((f) => f.role === (kind === 'music' ? 'audio' : 'video'));
255
+ return {
256
+ slug: e.slug, kind: e.kind, title: e.title, blurb: e.blurb ?? '', duration: e.duration ?? null,
257
+ page: `${origin}/${kind}/${e.slug}/`, [kind === 'music' ? 'audio' : 'video']: abs(main?.url), [kind === 'music' ? 'cover' : 'poster']: abs(mediaArt(e, kind, cat)),
258
+ credits: e.credits ?? null, rights: e.rights ?? null, for: e.for ?? null,
259
+ };
260
+ }
261
+
262
+ const KNOCK_CORS = { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, POST', 'access-control-allow-headers': 'content-type', 'access-control-max-age': '86400' };
263
+
264
+ async function chatWho(env, game, srv, holder) {
265
+ const pid = holders(holder).find((x) => x.startsWith('p-'))?.slice(2) ?? null;
266
+ if (!pid || !env.DB) return { acct: false, member: false };
267
+ let acct = false;
268
+ try { acct = Number((await env.DB.prepare('SELECT guest FROM players WHERE id = ?1').bind(pid).first())?.guest) === 0; } catch { acct = false; }
269
+ const member = acct && (!srv || srv.id === 'public' || Boolean(await memberOf(env, game, srv.id, pid)));
270
+ return { acct, member };
271
+ }
272
+
273
+ const reportsMade = new Map();
274
+
275
+ function reportAllowed(key, now = Date.now()) {
276
+ const list = (reportsMade.get(key) ?? []).filter((at) => now - at < 10 * 60_000);
277
+ if (list.length >= 8) { reportsMade.set(key, list); return false; }
278
+ list.push(now);
279
+ reportsMade.set(key, list);
280
+ if (reportsMade.size > 5000) for (const [k, v] of reportsMade) if (!v.some((at) => now - at < 10 * 60_000)) reportsMade.delete(k);
281
+ return true;
282
+ }
283
+
284
+ function notAHomie(request) {
285
+ if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...KNOCK_CORS, 'cache-control': 'no-store' } });
286
+ return json({ ok: false, error: 'not-a-homie', message: 'this is a studio site, not a Homie box; a game here plays on its own' }, 200, KNOCK_CORS);
287
+ }
288
+
289
+ async function gameDocument(request, env, url, game, meta, cat, { agent = null } = {}) {
290
+ const embedded = url.searchParams.get('embed') === '1' && playerEnabled(cat, meta);
291
+ const asked = url.searchParams.get('room');
292
+ // A player card's game joins a public room, or `main`: the room Play itself falls back to when the lobby does not answer.
293
+ if (embedded && (!/^(?:pub-[1-9][0-9]*|main)$/.test(asked || '') || url.searchParams.has('t') || url.searchParams.has('watch') || url.searchParams.get('want') === 'screen')) return new Response('Player cards join public rooms through the player address.\n', { status: 400 });
294
+ // A room code the relay cannot use is refused here too, never swapped for another room.
295
+ if (asked !== null && !ROOM_ID.test(asked)) return new Response('That room link does not work: a room code is 1 to 32 letters, digits, - or _.\n', { status: 400, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
296
+ const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/index.html`));
297
+ if (!res.ok) return notFoundPage(`No build of "${game}" on this site yet.`, cat);
298
+ const room = asked ?? 'main';
299
+ // A watcher (section 16): a screen that never takes a seat, following the player the watch page asks for.
300
+ const policy = watchOf(meta);
301
+ const watching = !agent && url.searchParams.get('watch') === '1';
302
+ if (watching && policy === 'off') return new Response(`${meta?.name ?? game} cannot be watched; play it instead.\n`, { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
303
+ const follow = /^(?:auto|overview|\d+)$/.test(url.searchParams.get('follow') ?? '') ? url.searchParams.get('follow') : 'auto';
304
+ const want = !agent && (watching || url.searchParams.get('want') === 'screen') ? 'screen' : 'play';
305
+ const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
306
+ const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
307
+ // The browser's room key (b: what a kick holds out) and the play page's ticket (t: a game that is not public) ride
308
+ // in the socket's address, which every netplay helper opens as it is given.
309
+ const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
310
+ const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
311
+ // The game's revision (game.json `netplay.version`, NETPLAY.md section 23) as this page is served: it rides in the
312
+ // socket's address (`gv`), so a build whose helper predates revisions is still known for the build it is.
313
+ const ver = versionOf(meta?.netplay?.version);
314
+ // The play page's switches for the game (section 24): the allowed names only, checked again here.
315
+ const params = paramsFrom(url.searchParams, meta);
316
+ if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
317
+ const cfg = {
318
+ ...(meta.kind === 'app' ? {kind: 'app'} : {}),
319
+ v: 1,
320
+ url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
321
+ room,
322
+ ...(ver ? { ver } : {}),
323
+ ...(Object.keys(params).length ? { params } : {}),
324
+ // The page around this frame keeps net.prefs (the play page says so; a watch page or an older page does not).
325
+ ...(url.searchParams.get('pf') === '1' ? { prefs: true } : {}),
326
+ ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
327
+ ...(agent ? { name: agent.name } : url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
328
+ // An AI that runs the game itself (hands `self`): its hello says so; the relay knows it from its ticket anyway.
329
+ ...(agent ? { agent: { hands: agent.hands, role: agent.role } } : {}),
330
+ // The play page's "Quiet AI": this browser hides AI speech (the helper's `net.hushed`).
331
+ ...(url.searchParams.get('hush') === '1' ? { hush: true } : {}),
332
+ // Room chat (section 19): this browser hides chat ("Show chat" off), or its player's own lines stay off their character.
333
+ ...(url.searchParams.get('chat') === '0' ? { chatOff: true } : {}),
334
+ ...(url.searchParams.get('bub') === '0' ? { bubbleOff: true } : {}),
335
+ ...(device ? { device } : {}),
336
+ want,
337
+ ...(watching ? { watch: true, follow: /^\d+$/.test(follow) ? Number(follow) : follow, watchPolicy: policy } : {}),
338
+ debug: url.searchParams.get('debug') === '1',
339
+ ...(meta?.movement ? { movement: meta.movement } : {}),
340
+ // game.json "saves": the play shell around this frame answers @homie-rocks/studio/saves (saves/SAVES.md).
341
+ ...(meta?.saves && want === 'play' ? { saves: true } : {}),
342
+ // The studio sells something in this game (shop/SHOP.md): the play shell answers @homie-rocks/studio/shop. A
343
+ // watcher has no shop; the shell follows the studio shop policy for kids servers and television checkout.
344
+ ...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
345
+ };
346
+ let html = await res.text();
347
+ const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
348
+ let assetBase='';
349
+ const assetLaunch=meta.kind==='app'?launchOf(meta,await settingsOf(env,{fresh:true}),env):'public';
350
+ if(meta.kind==='app'&&!url.pathname.includes('/__assets/')&&assetLaunch!=='public') {
351
+ const ticket=await appAssetTicket(env,game,undefined,{request,meta,launch:assetLaunch});
352
+ if(!ticket)return new Response('Sign in again to load this private app.',{status:403,headers:{'cache-control':'no-store'}});
353
+ assetBase=`<base href="/${game}/__assets/${ticket}/">`;
354
+ }
355
+ const head = assetBase + `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
356
+ html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
357
+ return new Response(html, {
358
+ headers: {
359
+ 'content-type': 'text/html; charset=utf-8',
360
+ 'cache-control': 'no-store, no-transform',
361
+ 'access-control-allow-origin': '*',
362
+ // The game runs in an opaque origin: it cannot read this site's storage or cookies.
363
+ 'content-security-policy': `sandbox allow-scripts allow-pointer-lock allow-forms allow-modals allow-popups${meta.kind === 'app' ? ' allow-downloads' : ''}; frame-ancestors ${embedded ? `'self' ${embedAncestors(cat, url.origin, env.HOMIE_EMBED_PREVIEW === '1').replace("'none'", '')}` : frameAncestors(cat)}`,
364
+ },
365
+ });
366
+ }
367
+
368
+ async function gameAsset(request, env, url, game, rest) {
369
+ if (rest.includes('..')) return new Response('not found', { status: 404 });
370
+ const target = new URL(`${url.origin}/games/${game}/${rest}`);
371
+ const res = MEDIA_FILE.test(rest) && request.headers.get('range') ? await assetWithRange(request, env, target) : await env.ASSETS.fetch(new Request(target));
372
+ const out = new Response(res.body, res);
373
+ // Module scripts from the opaque-origin frame are CORS requests.
374
+ out.headers.set('access-control-allow-origin', '*');
375
+ return out;
376
+ }
377
+
378
+ let sharedDay = null;
379
+
380
+ function shareDaily(cat, url, ctx) {
381
+ if (!cat.studio?.stats?.share || url.protocol !== 'https:' || sharedDay === today()) return;
382
+ sharedDay = today();
383
+ const directory = String(cat.studio.directory ?? 'https://homie.rocks').replace(/\/+$/, '');
384
+ const ping = fetch(`${directory}/api/studio/played`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ site: url.origin }) }).catch(error => console.warn("Directory play notification failed:",error.message));
385
+ if (ctx?.waitUntil) ctx.waitUntil(ping);
386
+ }
387
+
388
+ async function mediaBeat(request, env, ctx, url) {
389
+ const site = request.headers.get('sec-fetch-site');
390
+ const origin = request.headers.get('origin');
391
+ if (origin && origin !== 'null' ? origin !== url.origin : site !== 'same-origin') return new Response(null, { status: 403 });
392
+ if (Number(request.headers.get('content-length') ?? 0) > 256) return new Response(null, { status: 413 });
393
+ let body = null;
394
+ try { body = JSON.parse(await request.text()); } catch { return new Response(null, { status: 400 }); }
395
+ const kind = body?.k === 'song' || body?.k === 'video' ? body.k : null;
396
+ const slug = typeof body?.s === 'string' && MEDIA_SLUG.test(body.s) ? body.s : null;
397
+ if (!kind || !slug) return new Response(null, { status: 400 });
398
+ const cat = await catalogue(env, url.origin);
399
+ const known = (kind === 'song' ? cat.songs : cat.videos) ?? [];
400
+ if (!known.some((e) => e.slug === slug)) return new Response(null, { status: 404 });
401
+ if (!isQa(request)) await count(env, ctx, { metric: kind, subject: slug }); // house QA is never counted
402
+ return new Response(null, { status: 204, headers: { 'cache-control': 'no-store' } });
403
+ }
404
+
405
+
406
+ async function notifyTools(env,cat,origin) {
407
+ if(!env.DB||!env.LOBBY)return;
408
+ const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
409
+ await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
410
+ }
411
+
412
+
413
+ async function serverLive(env, meta, servers, origin, lobby) {
414
+ let list = [];
415
+ try { list = (await (await lobby().fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
416
+ const by = {};
417
+ for (const r of list) {
418
+ if (!(r.players > 0)) continue;
419
+ const sid = r.server ?? roomServer(r.name);
420
+ const b = (by[sid] ??= { playing: 0, ai: 0, rooms: 0 });
421
+ b.playing += r.players; b.ai += Number(r.ai ?? r.agents ?? 0) || 0; b.rooms += 1;
422
+ }
423
+ const members = env.DB ? await memberCounts(env, meta.id) : {};
424
+ return servers.map((s) => ({ ...serverView(s, { origin, game: meta.id }), playing: by[s.id]?.playing ?? 0, ai: by[s.id]?.ai ?? 0, live: by[s.id]?.rooms ?? 0, members: members[s.id] ?? 0 }));
425
+ }
426
+
427
+ async function weekOf(env, game) {
428
+ const w = await playedThisWeek(env, game);
429
+ if (!w) return null;
430
+ let peak = 0;
431
+ try { peak = (await (await env.LOBBY.get(env.LOBBY.idFromName(game)).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
432
+ return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
433
+ }
434
+
435
+ async function fallback(request, env, url, getCat) {
436
+ const asset = MEDIA_FILE.test(url.pathname) && request.headers.get('range') ? await assetWithRange(request, env, url) : await env.ASSETS.fetch(request);
437
+ if (asset.status === 404) {
438
+ // A manifest file outside music/ and videos/ that media move put in R2 keeps its address too.
439
+ const held = (request.method === 'GET' || request.method === 'HEAD') ? await heldInR2(env, getCat, url.pathname) : null;
440
+ if (held) return mediaObject(request, env, held.r2, { cache: ASSET_CACHE, type: held.type ?? null });
441
+ return notFoundPage('Nothing here.', await getCat());
442
+ }
443
+ return asset;
444
+ }
445
+ export {GAME_ID,POST_SLUG,GAME_FILES,MEDIA_FILE,BROWSER_KEY,TICKET_TEXT,json,sellingShop,availableShellShop,postsOf,CLAIM,claimed,claimMissed,storedClaim,REPO,repoOf,repoSaid,claimOf,named,roomsOf,MEDIA_SLUG,parseRange,assetWithRange,etagMatches,mediaObject,ASSET_CACHE,heldInR2,mediaRow,KNOCK_CORS,chatWho,reportsMade,reportAllowed,notAHomie,gameDocument,gameAsset,sharedDay,shareDaily,mediaBeat,finish,notifyTools,studioTools,serverLive,weekOf,fallback};
package/worker/seats.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  * `maxPlayers` or `players.max`); every room of that game has that many, as chosen by the studio.
6
6
  */
7
7
 
8
- import { stallOf, versionOf } from './room.mjs';
8
+ import { stallOf, versionOf } from './room-protocol.mjs';
9
9
 
10
10
  /** Room size is chosen by the studio, not capped by admission. */
11
11
  export const seatCount = (n, fallback = 8) => Number.isSafeInteger(Number(n)) && Number(n) > 0 ? Number(n) : fallback;
@@ -3,6 +3,7 @@ export * from '../index.mjs';
3
3
  let loaded;
4
4
  export const loadSelling = () => loaded ??= import('./runtime.mjs');
5
5
  export default {
6
+ async queue(batch,env,ctx) {return (await loadSelling()).default.queue(batch,env,ctx);},
6
7
  async fetch(request, env, ctx) { return (await loadSelling()).default.fetch(request, env, ctx); },
7
8
  async scheduled(event, env, ctx) { return (await loadSelling()).default.scheduled(event, env, ctx); },
8
9
  };
@@ -32,12 +32,14 @@ import { testPaymentExchange } from '../payment-test-exchange.mjs';
32
32
  useSelling({ enabled: true, customerTools, paidTool, productDescription, entryDescription, discoveryRoutes, officeScript, officeRoutes, refundOrder, extendOffice, testPaymentExchange, jobsSQL, manualRefundDetails, confirmManualRefund, listOffers, orderFact, resourceOrder, currentPurchaseOrder, purchasePaymentEvent, refundPurchasePayment, purchaseReadiness, purchaseRoutes, recordOrderState, authorizePurchaseTest, expirePurchaseCheckouts, reissuePurchaseClaim, retireResource, reconcileRecordings, machineCapabilities, paidFile, priceWords, purchaseDiscovery });
33
33
  export default { ...worker,
34
34
  async scheduled(controller, env, ctx) {
35
- await worker.scheduled(controller, env, ctx);
36
- if(env.DB)await pruneOffers(env);
37
- if (!env.PURCHASE_MACHINE_PAYMENTS || !env.DB) return;
38
- const { refreshMachineCapabilities } = await import('../payment-capabilities.mjs');
39
35
  const errors = [];
40
- for (const step of [reconcileRecordings, refreshMachineCapabilities, expirePurchaseCheckouts]) {
36
+ const steps = [() => worker.scheduled(controller, env, ctx)];
37
+ if(env.DB)steps.push(pruneOffers);
38
+ if(env.PURCHASE_MACHINE_PAYMENTS && env.DB) {
39
+ const { refreshMachineCapabilities } = await import('../payment-capabilities.mjs');
40
+ steps.push(reconcileRecordings, refreshMachineCapabilities, expirePurchaseCheckouts);
41
+ }
42
+ for (const step of steps) {
41
43
  try { await step(env); } catch (error) { errors.push(error); }
42
44
  }
43
45
  if (errors.length) throw new AggregateError(errors, 'Selling maintenance needs retry');
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  /**
2
3
  * SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
3
4
  * own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
@@ -171,8 +172,8 @@ const serverCache = new WeakMap();
171
172
  /** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
172
173
  async function rowsOf(env, { fresh = false } = {}) {
173
174
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
174
- const hit = serverCache.get(env.DB);
175
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
175
+ const hit = serverCache.get(databaseBinding(env.DB));
176
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
176
177
  let map = new Map();
177
178
  try {
178
179
  const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
@@ -182,10 +183,10 @@ async function rowsOf(env, { fresh = false } = {}) {
182
183
  map.set(r.game, list);
183
184
  }
184
185
  } catch { map = new Map(); }
185
- serverCache.set(env.DB, { at: Date.now(), map });
186
+ serverCache.set(databaseBinding(env.DB), { at: Date.now(), map });
186
187
  return map;
187
188
  }
188
- export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
189
+ export const forgetServers = (env) => { if (env?.DB) serverCache.delete(databaseBinding(env.DB)); };
189
190
 
190
191
  /**
191
192
  * A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's
@@ -7,7 +7,7 @@ export async function serviceResult(env,id,handler,refund,{now=Date.now,leaseMs=
7
7
  const fail=async()=>env.DB.prepare(`UPDATE service_results SET state='refund-pending' WHERE order_id=?1 AND state='running'`).bind(id).run();
8
8
  if(claimed.meta.changes) {
9
9
  let renewal=Promise.resolve();
10
- const timer=setInterval(()=>{renewal=renewal.then(()=>env.DB.prepare(`UPDATE service_results SET due=?3 WHERE order_id=?1 AND lease=?2 AND state='running'`).bind(id,lease,now()+leaseMs).run()).catch(()=>{});},leaseMs/3);
10
+ const timer=setInterval(()=>{renewal=renewal.then(()=>env.DB.prepare(`UPDATE service_results SET due=?3 WHERE order_id=?1 AND lease=?2 AND state='running'`).bind(id,lease,now()+leaseMs).run()).catch(error=>console.warn("Service lease renewal failed:",error.message));},leaseMs/3);
11
11
  try {
12
12
  const value=await handler();
13
13
  await env.DB.prepare(`UPDATE service_results SET state='complete',result=?3 WHERE order_id=?1 AND lease=?2 AND state='running'`).bind(id,lease,JSON.stringify(value??null)).run();
@@ -54,6 +54,6 @@ export async function loadShopLinks(env, origin='https://studio.invalid') {
54
54
  try {
55
55
  const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
56
56
  if(response.ok)value=await response.json();
57
- } catch {}
58
- return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
57
+ } catch(error) { console.warn('Built shop links unavailable:',error.message); }
58
+ return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),...(value?.purchases?{PURCHASE_LINKS:JSON.stringify(value.purchases)}:{}),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
59
59
  }
@@ -298,6 +298,7 @@ export const SHOP_JS = String.raw`(function () {
298
298
  (function poll() {
299
299
  tries += 1;
300
300
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
301
+ if (r.status === 'review') { $('[data-thanks-title]').textContent='Payment received'; $('[data-thanks-line]').textContent=r.note || 'Staff are reviewing your payment and will arrange fulfillment or a refund.'; return; }
301
302
  if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
302
303
  if (tries < 40) setTimeout(poll, 1500);
303
304
  else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
@@ -555,6 +556,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
555
556
  [['Payments', s.stripe.payments], ['Refunds', s.stripe.refunds], ['Disputes', s.stripe.disputes], ['Payouts', s.stripe.payouts], ['Balance', s.stripe.balance], ['Tax', s.stripe.tax]].concat(s.stripe.managedPayments ? [['Managed Payments', s.stripe.managedPayments]] : []).forEach(function (l) { links.appendChild(link(l[1], l[0])); });
556
557
  links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
557
558
  top.appendChild(links);
559
+ if (s.delivery?.stuck) top.appendChild(el('p', { class: 'note', role: 'status' }, s.delivery.stuck + ' delayed Stripe deliveries. ' + s.delivery.message));
558
560
  if (s.keyless) top.appendChild(el('p', { class: 'note' }, 'Refund in Stripe or ask your AI connected to Stripe. If a payment is missing here, ask your AI to resend its Stripe webhook.'));
559
561
  for (const total of s.totalsByCurrency ?? (s.totals ? [s.totals] : [])) top.appendChild(el('p', { class: 'dim' }, 'Orders in the last 30 days: ' + total.sales + ' paid orders, ' + cents(total.paid, total.currency) + ' before tax and fees (subscription renewals are in Stripe); ' + total.refunds + ' refunded (' + cents(total.refunded, total.currency) + '); ' + total.disputes + ' disputed now, ' + total.lost + ' lost.'));
560
562
  /* purchase-office */
@@ -183,10 +183,11 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
183
183
  else if (Array.isArray(games) && !games.includes(String(it.game))) err(`${at}.game`, `there is no game "${it.game}" in games/`);
184
184
  }
185
185
  if (it.taxCode !== undefined && !TAX_CODE.test(String(it.taxCode))) err(`${at}.taxCode`, 'taxCode is a Stripe product tax code such as "txcd_10000000"');
186
+ if(it.maxQuantity!==undefined && (!Number.isSafeInteger(it.maxQuantity)||it.maxQuantity<1||it.maxQuantity>999999))err(`${at}.maxQuantity`,'use a whole number from 1 to 999999');
186
187
  const advantage = it.advantage === true;
187
188
  const badge = gives.find((g) => g.startsWith('badge:'));
188
189
  for (const key of Object.keys(it)) {
189
- if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
190
+ if (!['id', 'kind', 'name', 'blurb', 'description', 'interval', 'price', 'min', 'max', 'gives', 'badge', 'days', 'starts', 'ends', 'game', 'taxCode', 'advantage', 'maxQuantity'].includes(key)) warnings.push({ at: `${at}.${key}`, message: 'This field is not published by the shop. Describe any buyer-facing meaning in blurb so buyers can read it.' });
190
191
  }
191
192
  items.push({
192
193
  id, kind, name, ...(it.interval === 'month' ? { interval: 'month' } : {}), ...(blurb ? { blurb } : {}),
@@ -194,7 +195,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
194
195
  gives, ...(badge ? { badge: badgeWord(badge, it) } : {}),
195
196
  ...(days !== null ? { days } : {}), ...(Number.isFinite(starts) ? { starts: new Date(starts).toISOString() } : {}), ...(Number.isFinite(ends) ? { ends: new Date(ends).toISOString() } : {}),
196
197
  ...(it.game ? { game: String(it.game) } : {}), ...(it.taxCode ? { taxCode: String(it.taxCode) } : {}),
197
- advantage,
198
+ advantage, ...(it.maxQuantity!==undefined?{maxQuantity:it.maxQuantity}:{}),
198
199
  });
199
200
  });
200
201
 
@@ -1,3 +1,4 @@
1
+ import {databaseBinding} from './request-db.mjs';
1
2
  /**
2
3
  * The shop's rows in the studio's own D1 (@homie-rocks/studio 0.24.0, migration 0008_studio_shop.sql). Kept apart
3
4
  * from worker/shop.mjs so player accounts (worker/players.mjs) can export and forget a player's purchases without
@@ -137,7 +138,7 @@ const completeSchemas = new WeakSet();
137
138
  /** Name the first missing schema migration, including a present table with an older shape. */
138
139
  export async function migrationNeeded(env) {
139
140
  if (!env?.DB) return SHOP_MIGRATION_FILE;
140
- if (completeSchemas.has(env.DB)) return null;
141
+ if (completeSchemas.has(databaseBinding(env.DB))) return null;
141
142
  for (const [file, query] of [
142
143
  [SHOP_MIGRATION_FILE, 'SELECT 1 FROM shop_orders LIMIT 1'],
143
144
  [SHOP_RESERVATIONS_FILE, 'SELECT expires_at FROM shop_orders LIMIT 1'],
@@ -148,7 +149,7 @@ export async function migrationNeeded(env) {
148
149
  ]) {
149
150
  try { await env.DB.prepare(query).first(); } catch { return file; }
150
151
  }
151
- completeSchemas.add(env.DB);
152
+ completeSchemas.add(databaseBinding(env.DB));
152
153
  return null;
153
154
  }
154
155
 
@@ -244,7 +245,7 @@ export const orderByPayment = (env, payment) => env.DB.prepare('SELECT * FROM sh
244
245
  export function orderView(r) {
245
246
  return {
246
247
  id: r.id, refundedAmount: Number(r.refunded_amount ?? 0), item: r.item, game: r.game ?? null, amount: Number(r.amount), currency: r.currency, tax: r.tax === null ? null : Number(r.tax), total: r.total === null ? null : Number(r.total),
247
- status: r.status, till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
248
+ status: r.status, ...(r.status==='review'?{note:'Payment received. Staff will reconcile the payment or refund it.'}:{}), till: r.till, mode: r.mode, parent: Number(r.parent) === 1,
248
249
  createdAt: Number(r.created_at), paidAt: r.paid_at === null ? null : Number(r.paid_at), refundedAt: r.refunded_at === null ? null : Number(r.refunded_at),
249
250
  };
250
251
  }