@homie-rocks/studio 0.12.1 → 0.14.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.
@@ -0,0 +1,762 @@
1
+ /**
2
+ * THE STUDIO'S BACK OFFICE (@homie-rocks/studio 0.13.0): the studio's owner, and the owner's AI, see and run the
3
+ * studio's live games. Everything lives in the studio's own Worker and D1; homie.rocks stores none of it.
4
+ *
5
+ * /_studio/office the owner's live view: every live room of every game (players, bots, round,
6
+ * uptime) and who is in it, refreshing by itself; kick, mute, announce, close a room;
7
+ * each game's launch state, remix switch, room size and invites
8
+ * /_studio/confirm/<ask> a control the owner's AI asked for, waiting for the owner's one tap
9
+ * /_studio/api/... the same, as JSON, for the page, the in-game owner overlay, the CLI and the MCP
10
+ * /<game>/invite an invite code, spent for this browser's pass to an invite-only game
11
+ *
12
+ * WHO. Only the owner: the owner's signed-in browser (the one-time sign-in link the CLI mints with the studio's own
13
+ * Cloudflare login; from 0.13.0 its session cookie lives at / so the owner is recognised in their own games), or an
14
+ * office key (`homie-studio office key`, also minted with that login; D1 keeps only its SHA-256). A player can
15
+ * never be the owner: the session is an HttpOnly cookie no page or game can read, and the games run in a sandboxed,
16
+ * opaque-origin frame that can send no request as this site. With an office key, the AI can look, announce and
17
+ * make invites; a control that takes something away (kick, mute, closing a room, a game's launch state, remix
18
+ * switch or room size) only becomes an ASK, which the owner confirms with one tap in their signed-in browser.
19
+ * No key can confirm an ask.
20
+ *
21
+ * CONTROLS ARE SIGNED. The Worker hands a room a control as a `ctl` message signed with the studio's own office
22
+ * key (HMAC-SHA256, a random secret in D1 `meta`, made on first use), for one game and one room, valid for a
23
+ * minute, used once; the room's Table verifies it before room.mjs applies it (NETPLAY.md section 15).
24
+ *
25
+ * LAUNCH STATES, per game: `private` (the owner only), `invite` (an invite-only beta: invite links or codes, then
26
+ * this browser's pass), `public` (the default; listed). Live in D1 `office_games`; a game.json `"launch"` is a new
27
+ * game's state until the owner sets one, so a game can be private from its first deploy. A game that is not public
28
+ * is left out of the studio's pages, /api/games, /api/rooms and the directory manifest, so the directory drops it
29
+ * the next time it reads the studio; its own pages, rooms and play frame refuse anyone without access (the play
30
+ * frame and its sockets carry a signed ticket the play page mints). A Preview (no D1, an unlisted address of a
31
+ * branch under review) enforces no launch state. The remix switch publishes or withdraws the game's
32
+ * /games/<id>/source.json (a game.json `"share": { "source": false }` builds none to publish).
33
+ *
34
+ * PLAYER ACCOUNTS (worker/players.mjs, handed over in worker/index.mjs with `usePlayers()`): `of(request, env)` names
35
+ * the signed-in player in their play page's ticket, so their seat carries their account and a kick holds it on every
36
+ * device; `get(id, env)` names them in the office; `isOwner(request, env)` lets the owner's own account (a passkey,
37
+ * `homie-studio players owner`) count as the owner everywhere the owner's session does. A player with no account is
38
+ * a guest with a handle.
39
+ */
40
+ import { SEAT_MAX, seatsOf } from './seats.mjs';
41
+ import { OWNER_COOKIE, cookieValues, ownerAllowed, ownerSession } from './stats.mjs';
42
+ import { esc, layout, notFoundPage } from './site.mjs';
43
+ import { confirmPage, lockedPage, officePage } from './office-page.mjs';
44
+
45
+ export { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from './office-schema.mjs';
46
+
47
+ export const LAUNCH_STATES = Object.freeze(['private', 'invite', 'public']);
48
+ const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
49
+ const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
50
+ const ASK_ID = /^ask_[a-f0-9]{16}$/;
51
+ const INVITE_ID = /^[a-f0-9]{10}$/;
52
+ /** Invite codes: 8 letters and digits nobody misreads (no 0/O, 1/I/L), shown as XXXX-XXXX. */
53
+ const CODE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789';
54
+ const ASK_TTL_MS = 15 * 60_000;
55
+ const PASS_TTL_MS = 90 * 24 * 3600_000;
56
+ const TICKET_TTL_MS = 12 * 3600_000;
57
+ /** Who may stay in a live room after a launch change, by the kinds of holder a ticket names (o, i-…, p-…). */
58
+ const ALLOW = Object.freeze({ private: ['o'], invite: ['o', 'i'], public: null });
59
+
60
+ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
61
+ status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store, private', 'x-robots-tag': 'noindex', ...extra },
62
+ });
63
+ const enc = encodeURIComponent;
64
+ const oneLine = (v, max) => String(v ?? '').replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, max);
65
+
66
+ function randomHex(bytes) {
67
+ const raw = new Uint8Array(bytes);
68
+ crypto.getRandomValues(raw);
69
+ return [...raw].map((b) => b.toString(16).padStart(2, '0')).join('');
70
+ }
71
+ function b64url(bytes) {
72
+ let s = '';
73
+ for (const b of bytes) s += String.fromCharCode(b);
74
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
75
+ }
76
+ async function sha256(text) {
77
+ const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text));
78
+ return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, '0')).join('');
79
+ }
80
+ /** Equal strings, compared in time that does not depend on where they differ. */
81
+ function same(a, b) {
82
+ a = String(a); b = String(b);
83
+ let d = a.length ^ b.length;
84
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) d |= (a.charCodeAt(i) || 0) ^ (b.charCodeAt(i) || 0);
85
+ return d === 0;
86
+ }
87
+ /** JSON with every object's keys sorted: what a signature covers. */
88
+ export function canonical(v) {
89
+ if (Array.isArray(v)) return `[${v.map(canonical).join(',')}]`;
90
+ if (v && typeof v === 'object') return `{${Object.keys(v).sort().filter((k) => v[k] !== undefined).map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`;
91
+ return JSON.stringify(v ?? null);
92
+ }
93
+
94
+ /* ------------------------------------------------------------------ player accounts (a seam) */
95
+
96
+ let players = null;
97
+ /**
98
+ * The player-accounts API, handed over once by a studio's Worker that has it:
99
+ * `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
100
+ */
101
+ export function usePlayers(api) { players = api && typeof api === 'object' ? api : null; }
102
+
103
+ async function playerOf(env, id) {
104
+ if (!players?.get || !id) return null;
105
+ try {
106
+ const p = await players.get(id, env);
107
+ return p && typeof p === 'object' ? { id: String(p.id ?? id).slice(0, 64), handle: oneLine(p.handle ?? p.name, 40) || null, since: p.since ?? p.createdAt ?? null } : null;
108
+ } catch { return null; }
109
+ }
110
+
111
+ /**
112
+ * The player account this request is signed in as (`p-<id>`, a ticket's holder), when the studio has accounts
113
+ * (`players.of(request, env)`); null otherwise. A room then knows the account, so a kick holds it on every device.
114
+ */
115
+ export async function accountSub(request, env) {
116
+ if (!players?.of) return null;
117
+ try {
118
+ const p = await players.of(request, env);
119
+ return p && /^[A-Za-z0-9_-]{1,64}$/.test(String(p.id ?? '')) ? `p-${p.id}` : null;
120
+ } catch { return null; }
121
+ }
122
+
123
+ /** The owner: the signed-in owner's browser session, or (with player accounts) the owner's own account. */
124
+ export async function isOwner(request, env) {
125
+ if ((await ownerAllowed(request, env, { kinds: ['session'] })) === 'session') return true;
126
+ try { return players?.isOwner ? (await players.isOwner(request, env)) === true : false; } catch { return false; }
127
+ }
128
+
129
+ /* ------------------------------------------------------------------ each game's settings */
130
+
131
+ const settingsCache = new WeakMap();
132
+
133
+ /** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0003. */
134
+ export async function settingsOf(env, { fresh = false } = {}) {
135
+ if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
136
+ const hit = settingsCache.get(env.DB);
137
+ if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
138
+ let map = new Map();
139
+ try {
140
+ const { results } = await env.DB.prepare('SELECT game, launch, remix, max_players, updated_at FROM office_games LIMIT 500').all();
141
+ map = new Map((results ?? []).map((r) => [r.game, r]));
142
+ } catch { map = new Map(); }
143
+ settingsCache.set(env.DB, { at: Date.now(), map });
144
+ return map;
145
+ }
146
+ const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(env.DB); };
147
+
148
+ /** A game's launch state: the owner's (D1), else its game.json `launch`, else public. A Preview enforces none. */
149
+ export function launchOf(meta, settings, env) {
150
+ if (env?.HOMIE_PREVIEW === '1') return 'public';
151
+ const v = settings?.get(meta?.id)?.launch ?? meta?.launch ?? 'public';
152
+ return LAUNCH_STATES.includes(v) ? v : 'public';
153
+ }
154
+ /** Whether the game's source is offered for remix: built (game.json share.source) and not withdrawn by the owner. */
155
+ export function remixOf(meta, settings) {
156
+ if (meta?.landing?.source === false) return false;
157
+ return settings?.get(meta?.id)?.remix !== 0;
158
+ }
159
+ /** A room's seats: the game's own (its netplay manifest), lowered by the owner's room size. */
160
+ export function seatsFor(meta, settings) {
161
+ const own = seatsOf(meta);
162
+ const n = Math.floor(Number(settings?.get(meta?.id)?.max_players));
163
+ return n >= 1 ? Math.min(own, n) : own;
164
+ }
165
+ /** The catalogue with only its public games: what every list, the rooms and the directory manifest show. */
166
+ export function publicCatalogue(cat, settings, env) {
167
+ const games = (cat.games ?? []).filter((g) => launchOf(g, settings, env) === 'public');
168
+ const featured = cat.studio?.site?.featured;
169
+ const site = featured && !games.some((g) => g.id === featured) ? { ...cat.studio.site, featured: undefined } : cat.studio?.site;
170
+ return { ...cat, studio: cat.studio ? { ...cat.studio, ...(site ? { site } : {}) } : cat.studio, games };
171
+ }
172
+
173
+ /* ------------------------------------------------------------------ the office key: tickets and signed controls */
174
+
175
+ const keys = new WeakMap();
176
+ function hexBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i += 1) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; }
177
+
178
+ /** The studio's office secret (a random 32 bytes in D1 meta, made on first use), as an HMAC key; null without D1. */
179
+ async function officeKey(env) {
180
+ if (!env?.DB) return null;
181
+ if (keys.has(env.DB)) return keys.get(env.DB);
182
+ let hex = null;
183
+ try {
184
+ hex = (await env.DB.prepare("SELECT value FROM meta WHERE key = 'office_key'").first())?.value ?? null;
185
+ if (!/^[a-f0-9]{64}$/.test(String(hex))) {
186
+ await env.DB.prepare("INSERT OR IGNORE INTO meta (key, value) VALUES ('office_key', ?1)").bind(randomHex(32)).run();
187
+ hex = (await env.DB.prepare("SELECT value FROM meta WHERE key = 'office_key'").first())?.value ?? null;
188
+ }
189
+ } catch { hex = null; }
190
+ if (!/^[a-f0-9]{64}$/.test(String(hex))) return null;
191
+ const key = await crypto.subtle.importKey('raw', hexBytes(hex), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
192
+ keys.set(env.DB, key);
193
+ return key;
194
+ }
195
+ async function mac(key, text) { return b64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))); }
196
+
197
+ /**
198
+ * Who a ticket names: `o` the owner, `i-<invite>` an invited browser, `p-<player>` a player account, or two of them
199
+ * joined with `~` (an invited player who is signed in: `i-…~p-…`, so a kick holds the account too).
200
+ */
201
+ const PART = '(?:o|i-[a-f0-9]{10}|p-[A-Za-z0-9_-]{1,64})';
202
+ const SUB = new RegExp(`^${PART}(?:~${PART})?$`);
203
+ const TICKET = new RegExp(`^([a-z0-9]{6,12})\\.(${PART}(?:~${PART})?)\\.([A-Za-z0-9_-]{32})$`);
204
+ /** A ticket's holders, as parts. */
205
+ export const holders = (sub) => String(sub ?? '').split('~').filter(Boolean);
206
+ /** Two holders as one ticket subject (each kind once, the access first). */
207
+ export function joinHolders(...subs) {
208
+ const parts = [];
209
+ for (const part of subs.flatMap(holders)) if (!parts.some((p) => p[0] === part[0])) parts.push(part);
210
+ return parts.slice(0, 2).join('~') || null;
211
+ }
212
+
213
+ /** A ticket for one game (12 hours): the play page mints it for a browser it let in; the frame and its sockets carry it. */
214
+ export async function ticketFor(env, game, sub, ttlMs = TICKET_TTL_MS) {
215
+ if (!SUB.test(String(sub ?? '')) || !GAME_ID.test(String(game ?? ''))) return null;
216
+ const key = await officeKey(env);
217
+ if (!key) return null;
218
+ const exp = (Date.now() + ttlMs).toString(36);
219
+ return `${exp}.${sub}.${(await mac(key, `ticket|${game}|${exp}|${sub}`)).slice(0, 32)}`;
220
+ }
221
+
222
+ /** Who a ticket names, when it is this studio's, for this game, and unexpired; else null. */
223
+ export async function ticketSub(env, game, ticket) {
224
+ const m = TICKET.exec(String(ticket ?? ''));
225
+ if (!m || parseInt(m[1], 36) < Date.now()) return null;
226
+ const key = await officeKey(env);
227
+ if (!key) return null;
228
+ return same((await mac(key, `ticket|${game}|${m[1]}|${m[2]}`)).slice(0, 32), m[3]) ? m[2] : null;
229
+ }
230
+
231
+ /** A control for one room, signed for the room's Table (NETPLAY.md section 15): valid one minute, once. */
232
+ export async function signControl(env, { op, game, room, args = {} }) {
233
+ const key = await officeKey(env);
234
+ if (!key) return null;
235
+ const at = Date.now();
236
+ const ctl = { t: 'ctl', v: 1, op, game, room, args, at, exp: at + 60_000, n: randomHex(8) };
237
+ return { ...ctl, sig: await mac(key, `ctl|${canonical(ctl)}`) };
238
+ }
239
+
240
+ /** Why a control must be refused (a word), or null when it is the owner's, for this room, in time, and new. */
241
+ export async function verifyControl(env, ctl, { game, room }, seen = null) {
242
+ if (!ctl || typeof ctl !== 'object' || ctl.t !== 'ctl' || ctl.v !== 1 || typeof ctl.op !== 'string') return 'shape';
243
+ if (ctl.game !== game || ctl.room !== room) return 'room';
244
+ const now = Date.now();
245
+ if (!(Number(ctl.exp) > now) || !(Number(ctl.at) <= now + 5000) || Number(ctl.exp) - Number(ctl.at) > 120_000) return 'expired';
246
+ if (typeof ctl.n !== 'string' || !/^[a-f0-9]{16}$/.test(ctl.n)) return 'shape';
247
+ if (seen?.has(ctl.n)) return 'replayed';
248
+ const key = await officeKey(env);
249
+ if (!key) return 'no-key';
250
+ const { sig, ...rest } = ctl;
251
+ if (!same(await mac(key, `ctl|${canonical(rest)}`), String(sig ?? ''))) return 'signature';
252
+ if (seen) {
253
+ seen.set(ctl.n, Number(ctl.exp));
254
+ for (const [n, exp] of seen) if (exp < now) seen.delete(n);
255
+ }
256
+ return null;
257
+ }
258
+
259
+ /* ------------------------------------------------------------------ who may play a game that is not public */
260
+
261
+ const passCookie = (game) => `studio_pass_${game}`;
262
+
263
+ /** This browser's pass to an invite-only game (its invite still open), or null. */
264
+ async function passOf(request, env, game) {
265
+ if (!env?.DB) return null;
266
+ for (const value of cookieValues(request, passCookie(game)).filter((v) => /^[a-f0-9]{48}$/.test(v)).slice(0, 2)) {
267
+ try {
268
+ const row = await env.DB.prepare('SELECT p.invite, p.expires_at, i.revoked FROM office_passes p JOIN office_invites i ON i.id = p.invite WHERE p.hash = ?1 AND p.game = ?2')
269
+ .bind(await sha256(value), game).first();
270
+ if (row && !row.revoked && Number(row.expires_at) > Date.now()) return { invite: row.invite };
271
+ } catch { return null; }
272
+ }
273
+ return null;
274
+ }
275
+
276
+ async function inviteOpen(env, id) {
277
+ try {
278
+ const row = await env.DB.prepare('SELECT revoked FROM office_invites WHERE id = ?1').bind(id).first();
279
+ return Boolean(row) && !row.revoked;
280
+ } catch { return false; }
281
+ }
282
+
283
+ /**
284
+ * Whether this request may open a game in this launch state: `{ ok, sub, owner }`. A public game is everyone's; a
285
+ * private one the owner's; an invite-only one the owner's and every browser holding a pass.
286
+ */
287
+ export async function accessOf(request, env, game, launch) {
288
+ if (launch === 'public') return { ok: true, sub: null, owner: false };
289
+ if (await isOwner(request, env)) return { ok: true, sub: 'o', owner: true };
290
+ if (launch === 'invite') {
291
+ const pass = await passOf(request, env, game);
292
+ if (pass) return { ok: true, sub: `i-${pass.invite}`, owner: false };
293
+ }
294
+ return { ok: false, sub: null, owner: false };
295
+ }
296
+
297
+ /** Whether a ticket's holder may still play now (the state may have changed, or the invite been revoked, since it was minted). */
298
+ export async function ticketAllows(env, sub, launch) {
299
+ if (launch === 'public') return true;
300
+ const parts = holders(sub);
301
+ if (parts.includes('o')) return true;
302
+ const invite = parts.find((p) => p.startsWith('i-'));
303
+ if (launch === 'invite' && invite) return inviteOpen(env, invite.slice(2));
304
+ return false;
305
+ }
306
+
307
+ /** The page a visitor without access sees: an invite-only game's door (an invite code), or nothing at all. */
308
+ export function gatePage(cat, meta, launch, { code = '', error = '' } = {}) {
309
+ if (launch !== 'invite') {
310
+ return notFoundPage('Nothing here. (Is this your studio? Your AI signs you in from the studio folder: npx --no-install homie-studio office link)', cat);
311
+ }
312
+ const shown = String(code ?? '').toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
313
+ const pretty = shown.length === 8 ? `${shown.slice(0, 4)}-${shown.slice(4)}` : shown;
314
+ return layout(cat, {
315
+ title: `${meta.name} · invite-only beta`, page: 'invite', status: error ? 403 : 200, active: 'games',
316
+ extraHeaders: { 'x-robots-tag': 'noindex', 'cache-control': 'no-store, private' },
317
+ main: `<header class="head"><p class="kicker">Invite-only beta</p><h1>${esc(meta.name)}</h1>
318
+ <p class="lead">${esc(meta.name)} is in a private beta. ${shown ? 'You have an invite: join with one tap.' : 'Have an invite code? Enter it to play.'}</p>
319
+ ${error ? `<p class="lead" role="alert"><b>${esc(error)}</b></p>` : ''}
320
+ <form method="post" action="/${esc(meta.id)}/invite" class="keys" style="flex-wrap:wrap;gap:10px">
321
+ <input name="code" value="${esc(pretty)}" placeholder="XXXX-XXXX" autocomplete="one-time-code" autocapitalize="characters" spellcheck="false" maxlength="12" required aria-label="Invite code" style="font:600 18px/1 ui-monospace,Menlo,monospace;letter-spacing:.08em;padding:12px 14px;border-radius:12px;border:1px solid rgba(127,127,127,.35);background:transparent;color:inherit;min-width:12ch">
322
+ <button class="btn" type="submit">Join the beta</button></form></header>`,
323
+ });
324
+ }
325
+
326
+ /** POST /<game>/invite: an invite code becomes this browser's pass (a cookie for this game's pages only). */
327
+ export async function redeemInvite(request, env, url, cat, meta, settings) {
328
+ const launch = launchOf(meta, settings, env);
329
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403, headers: { 'cache-control': 'no-store' } });
330
+ if (launch === 'public') return Response.redirect(`${url.origin}/${meta.id}/play`, 303);
331
+ let code = '';
332
+ try { const form = await request.formData(); code = String(form.get('code') ?? ''); } catch { code = ''; }
333
+ const norm = code.toUpperCase().replace(/[^A-Z0-9]/g, '').slice(0, 8);
334
+ if (launch !== 'invite' || !env?.DB) return gatePage(cat, meta, launch);
335
+ const now = Date.now();
336
+ let row = null;
337
+ try { row = await env.DB.prepare('SELECT id, game, uses, max_uses, expires_at, revoked FROM office_invites WHERE code = ?1').bind(norm).first(); } catch { row = null; }
338
+ const bad = !row || row.game !== meta.id || row.revoked || (row.expires_at && Number(row.expires_at) < now) || (row.max_uses && Number(row.uses) >= Number(row.max_uses));
339
+ if (bad) return gatePage(cat, meta, launch, { code: norm, error: 'That invite code does not work (used up, ended, or for another game). Ask whoever sent it for a new one.' });
340
+ const pass = randomHex(24);
341
+ try {
342
+ await env.DB.batch([
343
+ env.DB.prepare('UPDATE office_invites SET uses = uses + 1 WHERE id = ?1').bind(row.id),
344
+ env.DB.prepare('INSERT INTO office_passes (hash, game, invite, expires_at) VALUES (?1, ?2, ?3, ?4)').bind(await sha256(pass), meta.id, row.id, now + PASS_TTL_MS),
345
+ env.DB.prepare('DELETE FROM office_passes WHERE expires_at < ?1').bind(now),
346
+ ]);
347
+ } catch { return gatePage(cat, meta, launch, { code: norm, error: 'Something went wrong; try again.' }); }
348
+ const secure = url.protocol === 'https:' ? '; Secure' : '';
349
+ return new Response(null, {
350
+ status: 303,
351
+ headers: { location: `/${meta.id}/play`, 'cache-control': 'no-store', 'set-cookie': `${passCookie(meta.id)}=${pass}; Path=/${meta.id}/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(PASS_TTL_MS / 1000)}${secure}` },
352
+ });
353
+ }
354
+
355
+ /* ------------------------------------------------------------------ talking to rooms */
356
+
357
+ const lobbyOf = (env, game) => env.LOBBY.get(env.LOBBY.idFromName(game));
358
+ const tableOf = (env, game, room) => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
359
+
360
+ /** The rooms of one game with people in them now, as its Lobby last heard (public and named). */
361
+ async function liveRooms(env, game) {
362
+ try { return ((await (await lobbyOf(env, game).fetch('https://lobby/office')).json()).rooms ?? []).filter((r) => ROOM_ID.test(String(r.room ?? ''))).slice(0, 40); } catch { return []; }
363
+ }
364
+
365
+ async function roomFacts(env, game, room, max) {
366
+ try {
367
+ const res = await tableOf(env, game, room).fetch(`https://table/__facts?game=${enc(game)}&room=${enc(room)}&max=${max}`);
368
+ return res.ok ? await res.json() : null;
369
+ } catch { return null; }
370
+ }
371
+
372
+ /** Hand one room one signed control; what the room says back. */
373
+ async function roomControl(env, meta, settings, room, op, args) {
374
+ const ctl = await signControl(env, { op, game: meta.id, room, args });
375
+ if (!ctl) return { ok: false, error: 'no-key', message: 'This studio has no database for its office yet: `npm run deploy` applies migration 0003_studio_office.sql.' };
376
+ try {
377
+ const res = await tableOf(env, meta.id, room).fetch(new Request(`https://table/__office?game=${enc(meta.id)}&room=${enc(room)}&max=${seatsFor(meta, settings)}`, { method: 'POST', body: JSON.stringify(ctl) }));
378
+ return await res.json();
379
+ } catch (error) { return { ok: false, error: 'room', message: String(error?.message ?? error).slice(0, 120) }; }
380
+ }
381
+
382
+ const roomLabel = (room) => { const m = /^pub-(\d+)$/.exec(room); return m ? `Room ${m[1]}` : room; };
383
+
384
+ /** One room as the office shows it. */
385
+ async function roomRow(env, meta, room, max) {
386
+ const f = await roomFacts(env, meta.id, room, max);
387
+ if (!f) return null;
388
+ const office = f.office ?? {};
389
+ const clients = await Promise.all((office.clients ?? []).map(async (c) => {
390
+ const parts = holders(c.via);
391
+ const pid = parts.find((p) => p.startsWith('p-'))?.slice(2) ?? c.player ?? null;
392
+ const invite = parts.find((p) => p.startsWith('i-'))?.slice(2) ?? null;
393
+ const account = pid ? await playerOf(env, pid) : null;
394
+ return {
395
+ id: c.id, seat: c.seat, name: c.name, device: c.device, role: c.role, want: c.want, hidden: Boolean(c.hidden), waiting: Boolean(c.waiting),
396
+ joinedAt: c.joinedAt, browser: c.browser, muted: Boolean(c.muted), mutedUntil: c.mutedUntil ?? null,
397
+ as: parts.includes('o') ? 'owner' : invite ? 'invited' : account ? 'player' : 'guest',
398
+ ...(invite ? { invite } : {}),
399
+ ...(account ? { account } : {}),
400
+ };
401
+ }));
402
+ const slots = Array.isArray(f.roster) ? f.roster : [];
403
+ return {
404
+ room, label: roomLabel(room), public: /^pub-\d+$/.test(room),
405
+ players: f.counts?.players ?? 0, screens: f.counts?.screens ?? 0, waiting: f.counts?.waiting ?? 0,
406
+ humans: f.counts?.humans ?? 0, bots: f.counts?.bots ?? 0, max: f.counts?.maxPlayers ?? max,
407
+ round: f.round && typeof f.round === 'object' ? { n: f.round.n ?? null, phase: f.round.phase ?? null, startedAt: f.round.startedAt ?? null, endsAt: f.round.endsAt ?? null } : null,
408
+ openedAt: office.openedAt ?? f.openedAt ?? null, closedUntil: office.closedUntil ?? null, snapHz: f.snapHz ?? 0,
409
+ regate: office.regate ?? null,
410
+ host: f.host ?? null, announce: f.announce ?? null,
411
+ slots: slots.slice(0, 64).map((s) => ({ slot: s.slot, seat: s.seat ?? null, name: oneLine(s.name, 40), bot: Boolean(s.bot) })),
412
+ clients, bans: office.bans ?? [], mutes: office.mutes ?? [],
413
+ };
414
+ }
415
+
416
+ async function invitesOf(env, game, origin) {
417
+ try {
418
+ const { results } = await env.DB.prepare('SELECT id, code, label, uses, max_uses, expires_at, revoked, created_at FROM office_invites WHERE game = ?1 ORDER BY created_at DESC LIMIT 100').bind(game).all();
419
+ return (results ?? []).map((r) => inviteView(r, game, origin));
420
+ } catch { return []; }
421
+ }
422
+ const prettyCode = (c) => `${String(c).slice(0, 4)}-${String(c).slice(4)}`;
423
+ function inviteView(r, game, origin) {
424
+ const ended = Boolean(r.revoked) || (r.expires_at && Number(r.expires_at) < Date.now()) || (r.max_uses && Number(r.uses) >= Number(r.max_uses));
425
+ return {
426
+ id: r.id, code: prettyCode(r.code), label: r.label ?? '', uses: Number(r.uses) || 0, maxUses: r.max_uses ?? null,
427
+ expiresAt: r.expires_at ?? null, revoked: Boolean(r.revoked), open: !ended, link: `${origin}/${game}/?invite=${prettyCode(r.code)}`,
428
+ };
429
+ }
430
+
431
+ /** The whole back office, now: every game, its settings, and every live room with who is in it. */
432
+ export async function officeView(env, cat, origin) {
433
+ const settings = await settingsOf(env, { fresh: true });
434
+ const games = await Promise.all((cat.games ?? []).map(async (g) => {
435
+ const launch = launchOf(g, settings, env);
436
+ const row = settings.get(g.id);
437
+ const max = seatsFor(g, settings);
438
+ const rooms = (await Promise.all((await liveRooms(env, g.id)).map((r) => roomRow(env, g, r.room, max)))).filter(Boolean)
439
+ .filter((r) => r.players + r.screens > 0 || r.closedUntil || r.bans.length);
440
+ rooms.sort((a, b) => b.players - a.players || a.room.localeCompare(b.room));
441
+ return {
442
+ id: g.id, name: g.name, launch, launchFrom: row?.launch ? 'office' : g.launch ? 'game.json' : 'default',
443
+ remix: remixOf(g, settings), remixBuilt: g.landing?.source !== false,
444
+ seats: seatsOf(g), maxPlayers: max, maxSet: Number(row?.max_players) >= 1 ? Number(row.max_players) : null,
445
+ play: `${origin}/${g.id}/play`, page: `${origin}/${g.id}/`,
446
+ invites: launch === 'invite' || (await hasInvites(env, g.id)) ? await invitesOf(env, g.id, origin) : [],
447
+ rooms, playing: rooms.reduce((n, r) => n + r.players, 0),
448
+ };
449
+ }));
450
+ return {
451
+ ok: true, kind: 'homie-studio-office', v: 1, studio: cat.studio?.name ?? 'Studio', site: origin, now: Date.now(),
452
+ accounts: Boolean(players?.get), games, playing: games.reduce((n, g) => n + g.playing, 0),
453
+ };
454
+ }
455
+ async function hasInvites(env, game) {
456
+ try { return Boolean(await env.DB.prepare('SELECT 1 AS x FROM office_invites WHERE game = ?1 LIMIT 1').bind(game).first()); } catch { return false; }
457
+ }
458
+
459
+ /* ------------------------------------------------------------------ the controls */
460
+
461
+ const DESTRUCTIVE = new Set(['kick', 'mute', 'close', 'game']);
462
+
463
+ /** An action, checked against the catalogue: `{ ok, action }` or `{ ok: false, error, message }`. */
464
+ function checkAction(cat, op, body) {
465
+ const meta = (cat.games ?? []).find((g) => g.id === body.game);
466
+ const room = typeof body.room === 'string' && ROOM_ID.test(body.room) ? body.room : null;
467
+ const minutes = Math.max(1, Math.min(24 * 60, Math.floor(Number(body.minutes) || 10)));
468
+ const bad = (message) => ({ ok: false, error: 'bad-request', message });
469
+ switch (op) {
470
+ case 'kick':
471
+ case 'mute': {
472
+ if (!meta) return bad('game is one of this studio\'s game ids');
473
+ if (!room) return bad('room is the room\'s code (pub-3, or a named room)');
474
+ const id = typeof body.id === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(body.id) ? body.id : null;
475
+ const seat = Number.isInteger(body.seat) && body.seat >= 0 && body.seat < SEAT_MAX ? body.seat : null;
476
+ if (id === null && seat === null) return bad('name the player: id (from the office) or seat');
477
+ return { ok: true, action: { op, game: meta.id, room, id, seat, minutes, ...(op === 'kick' ? { address: body.address === true, message: oneLine(body.message, 200) || null } : { off: body.off === true }), name: oneLine(body.name, 40) || null } };
478
+ }
479
+ case 'close': {
480
+ if (!meta) return bad('game is one of this studio\'s game ids');
481
+ if (!room) return bad('room is the room\'s code');
482
+ return { ok: true, action: { op, game: meta.id, room, minutes, reopen: body.reopen === true, message: oneLine(body.message, 200) || null } };
483
+ }
484
+ case 'announce': {
485
+ const text = oneLine(body.text, 280);
486
+ if (body.game !== undefined && body.game !== null && !meta) return bad('game is one of this studio\'s game ids, or leave it out for every game');
487
+ if (body.room !== undefined && body.room !== null && (!room || !meta)) return bad('room needs its game, and is the room\'s code');
488
+ const seconds = Math.max(5, Math.min(3600, Math.floor(Number(body.seconds) || 30)));
489
+ return { ok: true, action: { op, game: meta?.id ?? null, room, text, seconds } };
490
+ }
491
+ case 'game': {
492
+ if (!meta) return bad('game is one of this studio\'s game ids');
493
+ const launch = body.launch === undefined || body.launch === null ? undefined : String(body.launch);
494
+ if (launch !== undefined && !LAUNCH_STATES.includes(launch)) return bad('launch is private, invite or public');
495
+ const remix = body.remix === undefined || body.remix === null ? undefined : body.remix === true || body.remix === 'on';
496
+ let maxPlayers;
497
+ if (body.maxPlayers === null || body.maxPlayers === 'game') maxPlayers = null;
498
+ else if (body.maxPlayers !== undefined) {
499
+ maxPlayers = Math.floor(Number(body.maxPlayers));
500
+ if (!(maxPlayers >= 1)) return bad('maxPlayers is a number from 1 to the game\'s own seats, or null for the game\'s own');
501
+ maxPlayers = Math.min(maxPlayers, seatsOf(meta));
502
+ }
503
+ if (launch === undefined && remix === undefined && maxPlayers === undefined) return bad('say what to change: launch, remix or maxPlayers');
504
+ if (remix === true && meta.landing?.source === false) return bad(`${meta.name}'s source is not in its build (game.json "share": { "source": false }); set it to true and deploy first`);
505
+ return { ok: true, action: { op, game: meta.id, ...(launch !== undefined ? { launch } : {}), ...(remix !== undefined ? { remix } : {}), ...(maxPlayers !== undefined ? { maxPlayers } : {}) } };
506
+ }
507
+ default:
508
+ return bad('unknown control');
509
+ }
510
+ }
511
+
512
+ /** The action in plain words, for the owner's confirm page and the AI's card. */
513
+ export function describe(cat, a) {
514
+ const g = (cat.games ?? []).find((x) => x.id === a.game);
515
+ const gname = g?.name ?? a.game;
516
+ const where = a.room ? `${roomLabel(a.room)} of ${gname}` : gname;
517
+ const who = a.name ? `${a.name}${Number.isInteger(a.seat) ? ` (seat ${a.seat + 1})` : ''}` : Number.isInteger(a.seat) ? `the player in seat ${a.seat + 1}` : 'that player';
518
+ const mins = `${a.minutes} minute${a.minutes === 1 ? '' : 's'}`;
519
+ switch (a.op) {
520
+ case 'kick': return `Kick ${who} out of ${where}; they cannot come back to that room for ${mins}${a.address ? ' (nor can anyone else on their network)' : ''}.`;
521
+ case 'mute': return a.off ? `Unmute ${who} in ${where}.` : `Mute ${who} in ${where} for ${mins}: their chat and emotes reach nobody.`;
522
+ case 'close': return a.reopen ? `Open ${where} again.` : `Close ${where}: everyone in it is sent out with a thank-you, and it stays closed for ${mins}.`;
523
+ case 'announce': return `Announce to ${a.room ? where : a.game ? `every room of ${gname}` : 'every room of every game'}: "${a.text}"`;
524
+ case 'game': {
525
+ const bits = [];
526
+ if (a.launch) bits.push({ private: `make ${gname} private (only you can open it; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, invite: `make ${gname} an invite-only beta (invited players only; after the current round everyone else leaves its rooms with a thank-you, and it leaves the directory on the next read)`, public: `make ${gname} public (anyone can play; listed in the directory on the next read)` }[a.launch]);
527
+ if (a.remix !== undefined) bits.push(a.remix ? `publish ${gname}'s source for remixing` : `withdraw ${gname}'s source from remixing`);
528
+ if (a.maxPlayers !== undefined) bits.push(a.maxPlayers === null ? `give ${gname}'s rooms the game's own number of seats` : `set ${gname}'s rooms to at most ${a.maxPlayers} players`);
529
+ const s = bits.join('; ');
530
+ return `${s.charAt(0).toUpperCase()}${s.slice(1)}.`;
531
+ }
532
+ default: return 'A control.';
533
+ }
534
+ }
535
+
536
+ /** Do it: the owner's own session did, or the owner confirmed what the AI asked. */
537
+ export async function perform(env, cat, a) {
538
+ const settings = await settingsOf(env, { fresh: true });
539
+ const meta = (cat.games ?? []).find((g) => g.id === a.game) ?? null;
540
+ switch (a.op) {
541
+ case 'kick':
542
+ case 'mute': {
543
+ const r = await roomControl(env, meta, settings, a.room, a.op, a.op === 'kick'
544
+ ? { id: a.id, seat: a.seat, minutes: a.minutes, address: a.address, ...(a.message ? { message: a.message } : {}) }
545
+ : { id: a.id, seat: a.seat, minutes: a.minutes, off: a.off });
546
+ return r;
547
+ }
548
+ case 'close': return roomControl(env, meta, settings, a.room, 'close', a.reopen ? { reopen: true } : { minutes: a.minutes, ...(a.message ? { message: a.message } : {}) });
549
+ case 'announce': {
550
+ const games = a.game ? [meta] : (cat.games ?? []);
551
+ const targets = [];
552
+ for (const g of games) {
553
+ if (a.room) targets.push([g, a.room]);
554
+ else for (const r of await liveRooms(env, g.id)) targets.push([g, r.room]);
555
+ }
556
+ const id = randomHex(4);
557
+ const results = await Promise.all(targets.slice(0, 200).map(([g, room]) => roomControl(env, g, settings, room, 'announce', { text: a.text, seconds: a.seconds, id })));
558
+ return { ok: true, op: 'announce', rooms: results.filter((r) => r.ok).length, people: results.reduce((n, r) => n + (r.people ?? 0), 0), text: a.text, cleared: !a.text };
559
+ }
560
+ case 'game': {
561
+ if (!env?.DB) return { ok: false, error: 'no-db', message: 'This studio has no D1 for its office.' };
562
+ const prev = launchOf(meta, settings, env);
563
+ const row = settings.get(meta.id) ?? {};
564
+ const next = {
565
+ launch: a.launch ?? row.launch ?? null,
566
+ remix: a.remix === undefined ? (row.remix ?? null) : (a.remix ? 1 : 0),
567
+ max: a.maxPlayers === undefined ? (row.max_players ?? null) : a.maxPlayers,
568
+ };
569
+ await env.DB.prepare(`INSERT INTO office_games (game, launch, remix, max_players, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
570
+ ON CONFLICT(game) DO UPDATE SET launch = excluded.launch, remix = excluded.remix, max_players = excluded.max_players, updated_at = excluded.updated_at`)
571
+ .bind(meta.id, next.launch, next.remix, next.max, Date.now()).run();
572
+ forgetSettings(env);
573
+ const fresh = await settingsOf(env, { fresh: true });
574
+ const launch = launchOf(meta, fresh, env);
575
+ const done = { ok: true, op: 'game', game: meta.id, launch, remix: remixOf(meta, fresh), maxPlayers: seatsFor(meta, fresh), regating: 0 };
576
+ const rooms = await liveRooms(env, meta.id);
577
+ // Who may play changed: every live room finishes its current round, with a notice, and then whoever the new
578
+ // state leaves out is sent out of it (the owner, and in an invite-only beta the invited, play on). Back to a
579
+ // wider state, a change still waiting is called off.
580
+ if (launch !== prev) {
581
+ const words = {
582
+ private: { notice: `${meta.name} goes private after this round. Thanks for playing!`, message: `${meta.name} is private now. Thanks for playing!` },
583
+ invite: { notice: `${meta.name} becomes an invite-only beta after this round. Invited players keep playing.`, message: `${meta.name} is an invite-only beta now. Have an invite? Enter it on the game's page. Thanks for playing!` },
584
+ public: {},
585
+ }[launch];
586
+ const res = await Promise.all(rooms.map((r) => roomControl(env, meta, fresh, r.room, 'regate', ALLOW[launch] ? { allow: ALLOW[launch], ...words } : {})));
587
+ done.regating = res.filter((r) => r.ok && r.leaving > 0).length;
588
+ }
589
+ if (a.maxPlayers !== undefined) {
590
+ await Promise.all(rooms.map((r) => roomControl(env, meta, fresh, r.room, 'seats', { max: done.maxPlayers })));
591
+ }
592
+ return done;
593
+ }
594
+ default: return { ok: false, error: 'op' };
595
+ }
596
+ }
597
+
598
+ async function makeAsk(env, cat, action, origin) {
599
+ const id = `ask_${randomHex(8)}`;
600
+ const now = Date.now();
601
+ await env.DB.batch([
602
+ env.DB.prepare('INSERT INTO office_asks (id, action, state, created_at, expires_at) VALUES (?1, ?2, ?3, ?4, ?5)').bind(id, JSON.stringify(action), 'pending', now, now + ASK_TTL_MS),
603
+ env.DB.prepare('DELETE FROM office_asks WHERE expires_at < ?1').bind(now - 24 * 3600_000),
604
+ ]);
605
+ return askView({ id, action: JSON.stringify(action), state: 'pending', result: null, created_at: now, expires_at: now + ASK_TTL_MS }, cat, origin);
606
+ }
607
+
608
+ function askView(row, cat, origin) {
609
+ let action = null; let result = null;
610
+ try { action = JSON.parse(row.action); } catch { action = null; }
611
+ try { result = row.result ? JSON.parse(row.result) : null; } catch { result = null; }
612
+ const state = row.state === 'pending' && Number(row.expires_at) < Date.now() ? 'expired' : row.state;
613
+ return { id: row.id, state, what: action ? describe(cat, action) : '', action, result, confirm: `${origin}/_studio/confirm/${row.id}`, expiresAt: Number(row.expires_at), createdAt: Number(row.created_at) };
614
+ }
615
+
616
+ async function readAsk(env, id) {
617
+ if (!ASK_ID.test(String(id ?? ''))) return null;
618
+ try { return await env.DB.prepare('SELECT * FROM office_asks WHERE id = ?1').bind(id).first(); } catch { return null; }
619
+ }
620
+
621
+ /** The name in a seat now (so an ask says who it is about). */
622
+ async function nameIn(env, cat, a) {
623
+ const meta = (cat.games ?? []).find((g) => g.id === a.game);
624
+ const f = meta ? await roomFacts(env, meta.id, a.room, seatsFor(meta, await settingsOf(env))) : null;
625
+ const c = (f?.clients ?? []).find((x) => (a.id && x.id === a.id) || (a.id === null && x.seat === a.seat));
626
+ return c ? { name: oneLine(c.name, 40), seat: c.seat } : null;
627
+ }
628
+
629
+ /* ------------------------------------------------------------------ routes */
630
+
631
+ /** A POST from this site's own page: its Origin, or (when a browser sends `Origin: null` or none) Sec-Fetch-Site. */
632
+ export function sameOrigin(request, url) {
633
+ const origin = request.headers.get('origin');
634
+ if (origin && origin !== 'null') return origin === url.origin;
635
+ return request.headers.get('sec-fetch-site') === 'same-origin';
636
+ }
637
+
638
+ async function invitesApi(request, env, url, cat, body, who) {
639
+ const origin = url.origin;
640
+ const meta = (cat.games ?? []).find((g) => g.id === (request.method === 'GET' ? url.searchParams.get('game') : body?.game));
641
+ if (!meta) return json({ ok: false, error: 'bad-request', message: 'game is one of this studio\'s game ids' }, 400);
642
+ if (request.method === 'GET') return json({ ok: true, game: meta.id, invites: await invitesOf(env, meta.id, origin) });
643
+ if (url.pathname.endsWith('/revoke')) {
644
+ if (!INVITE_ID.test(String(body.id ?? ''))) return json({ ok: false, error: 'bad-request', message: 'id is the invite\'s id' }, 400);
645
+ await env.DB.prepare('UPDATE office_invites SET revoked = 1 WHERE id = ?1 AND game = ?2').bind(body.id, meta.id).run();
646
+ return json({ ok: true, game: meta.id, revoked: body.id, by: who });
647
+ }
648
+ const count = Math.max(1, Math.min(20, Math.floor(Number(body.count) || 1)));
649
+ const uses = body.uses === null || body.uses === undefined || body.uses === 'any' ? null : Math.max(1, Math.min(10_000, Math.floor(Number(body.uses) || 1)));
650
+ const days = body.days === null || body.days === undefined ? null : Math.max(1, Math.min(365, Math.floor(Number(body.days) || 30)));
651
+ const label = oneLine(body.label, 60) || null;
652
+ const made = [];
653
+ for (let i = 0; i < count; i += 1) {
654
+ const raw = new Uint8Array(8);
655
+ crypto.getRandomValues(raw);
656
+ const code = [...raw].map((b) => CODE_ALPHABET[b % CODE_ALPHABET.length]).join('');
657
+ const row = { id: randomHex(5), code, label, uses: 0, max_uses: uses, expires_at: days ? Date.now() + days * 86400_000 : null, revoked: 0, created_at: Date.now() };
658
+ await env.DB.prepare('INSERT INTO office_invites (id, game, code, label, uses, max_uses, expires_at, revoked, created_at) VALUES (?1, ?2, ?3, ?4, 0, ?5, ?6, 0, ?7)')
659
+ .bind(row.id, meta.id, row.code, row.label, row.max_uses, row.expires_at, row.created_at).run();
660
+ made.push(inviteView(row, meta.id, origin));
661
+ }
662
+ return json({ ok: true, game: meta.id, launch: launchOf(meta, await settingsOf(env), env), invites: made });
663
+ }
664
+
665
+ async function api(request, env, url, cat) {
666
+ const path = url.pathname;
667
+ // An office key, the owner's signed-in browser, or the owner's own player account (a passkey).
668
+ let who = await ownerAllowed(request, env, { kinds: ['office', 'session'] });
669
+ if (!who && (await isOwner(request, env))) who = 'session';
670
+ if (!who) {
671
+ return json({ ok: false, error: 'owner-only', message: 'The back office is the studio owner\'s. In the studio folder, `npx --no-install homie-studio office key` gives the owner\'s AI a key; `npx --no-install homie-studio office link` signs the owner\'s browser in.' }, 401, { 'www-authenticate': 'Bearer' });
672
+ }
673
+ if (!env.TABLE || !env.LOBBY) return json({ ok: false, error: 'no-rooms', message: 'this Worker has no rooms bound' }, 503);
674
+ let body = {};
675
+ if (request.method === 'POST') {
676
+ if (who === 'session' && !sameOrigin(request, url)) return json({ ok: false, error: 'origin', message: 'a control comes from this site\'s own pages' }, 403);
677
+ if (!/^application\/json\b/i.test(request.headers.get('content-type') ?? '')) return json({ ok: false, error: 'content-type', message: 'send JSON' }, 415);
678
+ const text = await request.text();
679
+ if (text.length > 8192) return json({ ok: false, error: 'too-large' }, 413);
680
+ try { body = JSON.parse(text || '{}'); } catch { return json({ ok: false, error: 'json' }, 400); }
681
+ if (!body || typeof body !== 'object' || Array.isArray(body)) return json({ ok: false, error: 'json' }, 400);
682
+ } else if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
683
+
684
+ if (path === '/_studio/api/office' && request.method === 'GET') {
685
+ try { return json({ ...(await officeView(env, cat, url.origin)), by: who }); } catch (error) {
686
+ return json({ ok: false, error: 'no-office', message: `The office is not in this studio's D1 yet: \`npm run deploy\` applies migration 0003_studio_office.sql. (${String(error?.message ?? error).slice(0, 120)})` }, 503);
687
+ }
688
+ }
689
+ if (path === '/_studio/api/invites' || path === '/_studio/api/invites/revoke') {
690
+ if (path.endsWith('/revoke') && request.method !== 'POST') return json({ ok: false, error: 'method' }, 405);
691
+ try { return await invitesApi(request, env, url, cat, body, who); } catch (error) {
692
+ return json({ ok: false, error: 'no-office', message: `Invites need migration 0003_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
693
+ }
694
+ }
695
+ const askPath = /^\/_studio\/api\/asks\/(ask_[a-f0-9]{16})$/.exec(path);
696
+ if (askPath && request.method === 'GET') {
697
+ const row = await readAsk(env, askPath[1]);
698
+ return row ? json({ ok: true, ask: askView(row, cat, url.origin) }) : json({ ok: false, error: 'no-ask', message: 'No such ask: they last 15 minutes.' }, 404);
699
+ }
700
+ const op = /^\/_studio\/api\/(kick|mute|close|announce|game)$/.exec(path)?.[1];
701
+ if (!op || request.method !== 'POST') return json({ ok: false, error: 'not-found' }, 404);
702
+ const checked = checkAction(cat, op, body);
703
+ if (!checked.ok) return json(checked, 400);
704
+ const action = checked.action;
705
+ // An office key (the owner's AI) only ASKS for what takes something away; the owner confirms with one tap.
706
+ if (who === 'office' && DESTRUCTIVE.has(op)) {
707
+ if (op === 'kick' || op === 'mute') {
708
+ const found = await nameIn(env, cat, action);
709
+ if (!found) return json({ ok: false, error: 'no-player', message: 'Nobody is in that seat of that room now (studio_office lists who is).' }, 404);
710
+ action.name = found.name;
711
+ action.seat = found.seat;
712
+ }
713
+ try {
714
+ const ask = await makeAsk(env, cat, action, url.origin);
715
+ return json({ ok: true, needs: 'owner', ask, message: `Waiting for the owner: ${ask.what} The owner confirms it with one tap at ${ask.confirm} (signed in to the studio; it lasts 15 minutes).` }, 202);
716
+ } catch (error) {
717
+ return json({ ok: false, error: 'no-office', message: `Asks need migration 0003_studio_office.sql (npm run deploy). (${String(error?.message ?? error).slice(0, 120)})` }, 503);
718
+ }
719
+ }
720
+ const result = await perform(env, cat, action);
721
+ return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.error === 'no-player' ? 404 : 400);
722
+ }
723
+
724
+ /** /_studio/office, /_studio/confirm/<ask> and /_studio/api/*; null for any other path. */
725
+ export async function officeRoutes(request, env, url, { catalogueOf }) {
726
+ const path = url.pathname;
727
+ if (path.startsWith('/_studio/api/')) return api(request, env, url, await catalogueOf());
728
+ if (path === '/_studio/office' || path === '/_studio/office/') {
729
+ const cat = await catalogueOf();
730
+ if (request.method !== 'GET') return new Response('method', { status: 405 });
731
+ if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
732
+ // A session from before 0.13.0 lives only under /_studio/: carry it to / so the owner's games know them too.
733
+ const session = await ownerSession(request, env);
734
+ const secure = url.protocol === 'https:' ? '; Secure' : '';
735
+ return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
736
+ }
737
+ const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
738
+ if (confirm) {
739
+ const cat = await catalogueOf();
740
+ if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'confirm', ask: confirm[1] });
741
+ const row = await readAsk(env, confirm[1]);
742
+ if (!row) return confirmPage(cat, null, { missing: true });
743
+ const ask = askView(row, cat, url.origin);
744
+ if (request.method === 'GET') return confirmPage(cat, ask);
745
+ if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
746
+ let yes = false;
747
+ try { yes = String((await request.formData()).get('do') ?? '') === 'yes'; } catch { yes = false; }
748
+ if (ask.state !== 'pending') return confirmPage(cat, ask);
749
+ if (!yes) {
750
+ await env.DB.prepare("UPDATE office_asks SET state = 'cancelled' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
751
+ return confirmPage(cat, { ...ask, state: 'cancelled' });
752
+ }
753
+ // Claim it first, so a double tap does it once.
754
+ await env.DB.prepare("UPDATE office_asks SET state = 'working' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
755
+ const claimed = await readAsk(env, ask.id);
756
+ if (claimed?.state !== 'working') return confirmPage(cat, askView(claimed ?? row, cat, url.origin));
757
+ const result = await perform(env, cat, ask.action);
758
+ await env.DB.prepare('UPDATE office_asks SET state = ?2, result = ?3 WHERE id = ?1').bind(ask.id, result.ok ? 'done' : 'failed', JSON.stringify(result).slice(0, 4000)).run();
759
+ return confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
760
+ }
761
+ return null;
762
+ }