@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.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- 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/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- 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 +58 -2772
- 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 +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- 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 +78 -0
- 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/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- 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/servers.mjs
CHANGED
|
@@ -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
|
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 {}
|
|
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
|
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -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 */
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -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
|
|
package/worker/shop-store.mjs
CHANGED
|
@@ -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
|
}
|