@homie-rocks/studio 0.54.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.
- package/CHANGELOG.md +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +35 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- 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;
|
package/worker/selling/index.mjs
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/worker/service-run.mjs
CHANGED
|
@@ -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(()
|
|
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();
|
package/worker/shop-links.mjs
CHANGED
|
@@ -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 {}
|
|
57
|
+
} catch(error) { console.warn('Built shop links unavailable:',error.message); }
|
|
58
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
|
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -556,6 +556,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
556
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])); });
|
|
557
557
|
links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
|
|
558
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));
|
|
559
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.'));
|
|
560
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.'));
|
|
561
562
|
/* purchase-office */
|