@homie-rocks/studio 0.12.0 → 0.13.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/worker/index.mjs CHANGED
@@ -28,6 +28,10 @@
28
28
  * /media/<key> the studio's large media, from R2 (once `storage add` bound it), with byte ranges
29
29
  * /api/stats the studio's numbers, for its owner only (a read key, or the owner's page session)
30
30
  * /_studio/stats the owner's private stats page (one-time sign-in link from `homie-studio stats link`)
31
+ * /_studio/office the owner's back office: live rooms and who is in them, kick, mute, announce, close,
32
+ * each game's launch state (private, invite-only beta, public), remix switch, room size
33
+ * and invites (worker/office.mjs); /_studio/api/* is the same as JSON
34
+ * /<game>/invite an invite code spent for this browser's pass to an invite-only game
31
35
  * /_homie/site.js the pages' one script
32
36
  * /account/ a player's account: a passkey, a name, their data (worker/players.mjs, saves/SAVES.md)
33
37
  * /api/player/... sign in and up, saves, lifetime stats and memorials (the game's saves bridge, via the
@@ -60,10 +64,20 @@ import {
60
64
  } from './site.mjs';
61
65
  import { count, countVisit, counter, isQa, onlyOf, ownerAllowed, playedByGame, playedThisWeek, rangeOf, readStats, today } from './stats.mjs';
62
66
  import { ownerRoutes } from './stats-page.mjs';
63
- import { playerRoutes } from './players.mjs';
67
+ import { playerRoutes, players as playerAccounts } from './players.mjs';
68
+ import {
69
+ accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, officeRoutes, publicCatalogue, redeemInvite, remixOf, seatsFor, settingsOf, ticketAllows, ticketFor,
70
+ ticketSub, usePlayers, verifyControl,
71
+ } from './office.mjs';
72
+
73
+ // The back office knows the studio's player accounts: a signed-in player is named in the office and held by a kick on
74
+ // every device, and the owner's own account (`homie-studio players owner`) counts as the owner.
75
+ usePlayers(playerAccounts);
64
76
  import { STUDIO_VERSION_TAG } from './version.mjs';
65
77
 
66
78
  export { SEAT_MAX } from './seats.mjs';
79
+ /** For a studio whose Worker has player accounts: hand their server API to the back office once (worker/office.mjs). */
80
+ export { usePlayers } from './office.mjs';
67
81
  /** For a studio's own wrapper Worker: count its own pages the way the template counts its pages. */
68
82
  export { countVisit } from './stats.mjs';
69
83
  /** Players and cloud saves (saves/SAVES.md): `players.list`, `players.get`, `players.isOwner`… for the back office. */
@@ -74,6 +88,9 @@ const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
74
88
  /** A game's own files, framed by its play page: never x-frame-options DENY. */
75
89
  const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
76
90
  const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
91
+ /** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
92
+ const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
93
+ const TICKET_TEXT = /^[a-z0-9]{6,12}\.[A-Za-z0-9_~-]{1,140}\.[A-Za-z0-9_-]{32}$/;
77
94
 
78
95
  const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
79
96
  status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
@@ -115,11 +132,23 @@ const claimMissed = new Map();
115
132
  async function storedClaim(env, origin) {
116
133
  if (!env.DB) return null;
117
134
  try {
118
- const rows = (await env.DB.prepare('SELECT key, value FROM meta WHERE key IN (?1, ?2)').bind(`homie_claim:${origin}`, 'homie_claim').all()).results ?? [];
119
- return { own: rows.find((r) => r.key === `homie_claim:${origin}`)?.value ?? null, older: rows.find((r) => r.key === 'homie_claim')?.value ?? null };
135
+ 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 ?? [];
136
+ const of = (k) => rows.find((r) => r.key === k)?.value ?? null;
137
+ return { own: of(`homie_claim:${origin}`), older: of('homie_claim'), repo: of(`homie_repo:${origin}`) };
120
138
  } catch { return null; }
121
139
  }
122
140
 
141
+ /*
142
+ * THE STUDIO'S GITHUB REPOSITORY (HOMIE_REPO, set by `homie-studio deploy` from the studio's own git remote or
143
+ * studio.json "github"; lib/repo.mjs). The site tells its directory which repository it is built from, with its
144
+ * claim, so the Claude app's hand-off opens Claude Code on the studio's own repository. It is never put in a public
145
+ * page: only the directory hears it, from this Worker (Cloudflare's CF-Worker header names the zone it runs on),
146
+ * and it is said again only when it changes.
147
+ */
148
+ const REPO = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})\/[A-Za-z0-9._-]{1,100}$/;
149
+ const repoOf = (env) => { const r = String(env.HOMIE_REPO ?? '').trim(); return REPO.test(r) && r.toLowerCase() !== 'homie-rocks/homie' ? r : null; };
150
+ const repoSaid = new Map();
151
+
123
152
  export function directoryOf(cat) {
124
153
  const d = cat?.studio?.directory;
125
154
  if (d === null || d === false) return null;
@@ -133,28 +162,34 @@ export function directoryOf(cat) {
133
162
  /** This address's claim: kept, else asked of the directory now (and kept), else an older studio's single claim. */
134
163
  async function claimOf(env, cat, url) {
135
164
  const origin = url.origin;
136
- if (claimed.has(origin)) return claimed.get(origin);
165
+ const repo = repoOf(env);
166
+ if (claimed.has(origin) && (!repo || repoSaid.get(origin) === repo)) return claimed.get(origin);
137
167
  const kept = await storedClaim(env, origin);
138
- if (kept?.own) { claimed.set(origin, kept.own); return kept.own; }
168
+ if (kept?.own && (!repo || kept.repo === repo)) { claimed.set(origin, kept.own); if (repo) repoSaid.set(origin, repo); return kept.own; }
139
169
  const directory = directoryOf(cat);
140
170
  const local = url.protocol === 'http:' && ['127.0.0.1', 'localhost'].includes(url.hostname);
141
171
  const may = directory && env.DB && env.HOMIE_PREVIEW !== '1' && (url.protocol === 'https:' || (local && directory.startsWith('http://')))
142
172
  && Date.now() - (claimMissed.get(origin) ?? 0) > 60_000;
143
173
  if (may) {
144
174
  try {
145
- const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}`, {
175
+ // Asked when there is no claim yet, or to say a repository the directory has not heard from this site.
176
+ const res = await fetch(`${directory}/api/studio/claim?site=${encodeURIComponent(origin)}${repo ? `&repo=${encodeURIComponent(repo)}` : ''}`, {
146
177
  headers: { accept: 'application/json', 'user-agent': `homie-studio/${STUDIO_VERSION_TAG} (site claim)` }, signal: AbortSignal.timeout(5000),
147
178
  });
148
179
  const body = await res.json().catch(() => null);
149
180
  if (res.ok && CLAIM.test(String(body?.claim ?? ''))) {
150
- await env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim).run();
181
+ await env.DB.batch([
182
+ env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_claim:${origin}`, body.claim),
183
+ ...(repo ? [env.DB.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?1, ?2)').bind(`homie_repo:${origin}`, repo)] : []),
184
+ ]);
151
185
  claimed.set(origin, body.claim);
186
+ if (repo) repoSaid.set(origin, repo);
152
187
  return body.claim;
153
188
  }
154
189
  } catch { /* the directory is optional for a live site */ }
155
190
  claimMissed.set(origin, Date.now());
156
191
  }
157
- return kept?.older ?? null;
192
+ return kept?.own ?? kept?.older ?? null;
158
193
  }
159
194
 
160
195
  /** A copy of the public template nobody has named yet shows the name typed into Cloudflare's form (STUDIO_NAME). */
@@ -273,9 +308,13 @@ async function gameDocument(request, env, url, game, meta, cat) {
273
308
  const want = url.searchParams.get('want') === 'screen' ? 'screen' : 'play';
274
309
  const device = ['phone', 'desk', 'tv'].includes(url.searchParams.get('device')) ? url.searchParams.get('device') : undefined;
275
310
  const wsBase = `${url.protocol === 'https:' ? 'wss' : 'ws'}://${url.host}`;
311
+ // 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
312
+ // in the socket's address, which every netplay helper opens as it is given.
313
+ const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : null;
314
+ const t = TICKET_TEXT.test(url.searchParams.get('t') ?? '') ? url.searchParams.get('t') : null;
276
315
  const cfg = {
277
316
  v: 1,
278
- url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}`,
317
+ url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}`,
279
318
  room,
280
319
  ...(url.searchParams.get('k') ? { token: url.searchParams.get('k').slice(0, 128) } : {}),
281
320
  ...(url.searchParams.get('name') ? { name: url.searchParams.get('name').slice(0, 24) } : {}),
@@ -373,7 +412,10 @@ async function route(request, env, ctx) {
373
412
  const parts = path.split('/').filter(Boolean);
374
413
  const read = request.method === 'GET' || request.method === 'HEAD';
375
414
  let catP = null;
376
- const getCat = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
415
+ let pubP = null;
416
+ // Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
417
+ const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
418
+ const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
377
419
 
378
420
  // "Connect to Claude" (the template's first-run band): the directory's setup page, for this site's address.
379
421
  if (path === '/_studio/connect' && read) {
@@ -381,17 +423,17 @@ async function route(request, env, ctx) {
381
423
  if (!directory || env.HOMIE_PREVIEW === '1') return json({ ok: false, error: 'no-directory', message: 'This studio is not connected to a directory.' }, 404);
382
424
  return Response.redirect(`${directory}/studio/setup/connect?site=${encodeURIComponent(url.origin)}`, 302);
383
425
  }
384
- if (path.startsWith('/_studio/')) return ownerRoutes(request, env, url, { catalogueOf: getCat });
426
+ if (path.startsWith('/_studio/')) return (await officeRoutes(request, env, url, { catalogueOf: getAll })) ?? ownerRoutes(request, env, url, { catalogueOf: getAll });
385
427
  if (path === '/__homie' || path.startsWith('/__homie/')) return notAHomie(request);
386
428
  if (path.startsWith('/api/player/') || path === '/account' || path === '/account/' || path === '/_homie/account.js') return playerRoutes(request, env, ctx, url, { catalogueOf: getCat });
387
429
  if (path === '/_homie/site.js') return new Response(SITE_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': url.searchParams.get('v') === STUDIO_VERSION_TAG ? 'public, max-age=31536000, immutable' : 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
388
430
  if (path === '/api/stats/beat' && request.method === 'POST') return mediaBeat(request, env, ctx, url);
389
431
  if (path === '/api/stats') {
390
432
  if (request.method !== 'GET') return json({ ok: false, error: 'method' }, 405);
391
- if ((await ownerAllowed(request, env, { kinds: ['read'] })) !== 'read') {
433
+ if (!(await ownerAllowed(request, env, { kinds: ['read', 'office'] }))) {
392
434
  return json({ ok: false, error: 'owner-only', message: 'The studio\'s numbers are for its owner. In the studio folder: `npx --no-install homie-studio stats` prints them; `npx --no-install homie-studio stats key` gives a read key for the Homie MCP tool studio_stats.' }, 401, { 'www-authenticate': 'Bearer' });
393
435
  }
394
- const cat = await getCat();
436
+ const cat = await getAll();
395
437
  const studios = new Set(String(url.searchParams.get('studios') ?? '').split(',').filter((h) => /^[a-z0-9.-]{3,80}$/.test(h)).slice(0, 500));
396
438
  try {
397
439
  return json({ ok: true, site: url.origin, ...(await readStats(env, cat, { range: rangeOf(url.searchParams), only: onlyOf(url.searchParams), studios })) });
@@ -417,16 +459,20 @@ async function route(request, env, ctx) {
417
459
  // What is deployed: the commit, when, and the marks of the newest changes (a merged pull request is live when
418
460
  // its mark is here). A Preview says it is one.
419
461
  ...(cat.studio?.build ? { build: { commit: cat.studio.build.commit ?? null, branch: cat.studio.build.branch ?? null, at: cat.studio.build.at ?? null, changes: (cat.studio.build.changes ?? []).slice(0, 50), ...(env.HOMIE_PREVIEW === '1' ? { preview: true } : {}) } } : {}),
420
- games: (cat.games ?? []).map((g) => {
462
+ // Only the public games (a private or invite-only game leaves the directory the next time it reads this).
463
+ games: await Promise.all((cat.games ?? []).map(async (g) => {
421
464
  // The card picture is the landing's hero still (what the landing leads with), else the game's cover.
422
465
  const cover = gameCover(g);
466
+ const remix = remixOf(g, await settingsOf(env));
423
467
  return {
424
468
  id: g.id, name: g.name, blurb: g.blurb ?? '', players: g.players ?? null, roundSeconds: g.roundSeconds ?? null,
425
469
  page: `${url.origin}/${g.id}/`, play: `${url.origin}/${g.id}/play`, cover: cover ? (cover.startsWith('/') ? `${url.origin}${cover}` : cover) : null,
470
+ // The owner's remix switch (0.13.0): whether the source is open for other studios to remix, and where.
471
+ remix, ...(remix ? { source: `${url.origin}/games/${g.id}/source.json` } : {}),
426
472
  // Shared only with `stats.share`: this game's own Play presses and rounds with people, this week.
427
473
  ...(byGame ? { played: byGame[g.id] ?? { days: 7, plays: 0, rounds: 0 } } : {}),
428
474
  };
429
- }),
475
+ })),
430
476
  songs: (cat.songs ?? []).map((e) => mediaRow(e, url.origin, 'music', cat)),
431
477
  videos: (cat.videos ?? []).map((e) => mediaRow(e, url.origin, 'videos', cat)),
432
478
  // The studio's posts, for the hub (the full text is in /posts/feed.json).
@@ -461,6 +507,17 @@ async function route(request, env, ctx) {
461
507
  return path.endsWith('.xml') ? atomFeed(cat, posts, url.origin) : jsonFeed(cat, posts, url.origin);
462
508
  }
463
509
 
510
+ // A game's remix source is served while the game is public and its owner has not withdrawn it (the remix switch).
511
+ const src = /^\/games\/([a-z0-9][a-z0-9-]{0,39})\/source\.json$/.exec(path);
512
+ if (src) {
513
+ const all = await getAll();
514
+ const meta = (all.games ?? []).find((g) => g.id === src[1]);
515
+ if (meta) {
516
+ const settings = await settingsOf(env, { fresh: true });
517
+ if (launchOf(meta, settings, env) !== 'public' || !remixOf(meta, settings)) return json({ ok: false, error: 'not-shared', message: 'This game\'s source is not shared for remixing.' }, 404);
518
+ }
519
+ }
520
+
464
521
  // A page the studio made itself (site/pages) wins at its address.
465
522
  if (read && (path.endsWith('/') || !path.includes('.'))) {
466
523
  const cat = await getCat();
@@ -529,14 +586,24 @@ async function route(request, env, ctx) {
529
586
 
530
587
  const game = parts[0];
531
588
  if (game && GAME_ID.test(game)) {
532
- const cat = await getCat();
533
- const meta = (cat.games ?? []).find((g) => g.id === game);
589
+ const all = await getAll();
590
+ const meta = (all.games ?? []).find((g) => g.id === game);
534
591
  if (!meta) return fallback(request, env, url, getCat);
535
592
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
593
+ const cat = await getCat();
536
594
  const sub = parts.slice(1).join('/');
537
- const max = seatsOf(meta);
595
+ // Read now, not from a Worker instance's 5 s cache: a game the owner just made private shuts at once everywhere.
596
+ const settings = await settingsOf(env, { fresh: true });
597
+ // The owner's launch state and room size (worker/office.mjs): a private or invite-only game lets in only those
598
+ // with access, and the owner may have made its rooms smaller than the game's own seats.
599
+ const launch = launchOf(meta, settings, env);
600
+ const max = seatsFor(meta, settings);
538
601
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
602
+ if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
603
+ const door = launch !== 'public' && ['', 'live', 'credits', 'tv', 'play', 'api/lobby'].includes(sub) ? await accessOf(request, env, game, launch) : { ok: true, sub: null, owner: false };
604
+ const shut = () => gatePage(cat, meta, launch, { code: url.searchParams.get('invite') ?? '' });
539
605
  if (sub === '') {
606
+ if (!door.ok) return shut();
540
607
  await countVisit(request, env, ctx, game);
541
608
  shareDaily(cat, url, ctx);
542
609
  const { rooms, live } = await roomsOf(env, [meta]);
@@ -544,50 +611,72 @@ async function route(request, env, ctx) {
544
611
  return gameLanding(cat, meta, { origin: url.origin, rooms, playing: live[game] ?? 0, week });
545
612
  }
546
613
  if (sub === 'live') {
614
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
547
615
  const { rooms, live } = await roomsOf(env, [meta]);
548
616
  const week = cat.studio?.stats?.share ? await weekOf(env, game) : null;
549
617
  // The same shape the house brands' landings read (counted, playing, waiting, road), plus the rooms.
550
- return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: 'public', big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
618
+ return json({ ok: true, game, counted: true, playing: live[game] ?? 0, waiting: 0, max, rooms, road: { seats: 'any', start: 'now', room: launch === 'public' ? 'public' : launch, big: true }, ...(week ? { week } : {}) }, 200, { 'cache-control': 'no-store' });
551
619
  }
552
620
  if (sub === 'credits') {
621
+ if (!door.ok) return shut();
553
622
  let texts = [];
554
623
  try { const res = await env.ASSETS.fetch(new Request(`${url.origin}/games/${game}/_landing/credits.json`)); if (res.ok) texts = (await res.json()).texts ?? []; } catch { texts = []; }
555
624
  if (!texts.length) return notFoundPage(`${meta.name} has no licence texts to show; its credits are on its page.`, cat);
556
625
  return creditsPage(cat, meta, texts, { origin: url.origin });
557
626
  }
558
- if (sub === 'tv' || (sub === 'play' && url.searchParams.get('screen') === '1')) {
627
+ if (sub === 'tv' || sub === 'play') {
628
+ const screen = sub === 'tv' || url.searchParams.get('screen') === '1';
559
629
  const asked = url.searchParams.get('room');
560
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen: true });
561
- await countVisit(request, env, ctx, game, 'screen');
562
- // The big screen picks its room now, so the QR it shows puts every phone in that same room.
563
- let room = asked;
564
- if (!room) {
565
- try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
630
+ if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked, { screen });
631
+ if (!door.ok) return shut();
632
+ // A browser let into a game that is not public carries a ticket into its frame and sockets; the owner (signed
633
+ // in, in any game) also gets the owner's overlay, which no one else's page carries.
634
+ // The owner's page (in any game), a browser let into a game that is not public, and a signed-in player carry a
635
+ // ticket naming them (an invited player: the invite and the account), so the room knows who stays when the game
636
+ // narrows and whom a kick holds on every device.
637
+ const owner = door.owner || (launch === 'public' && await isOwner(request, env));
638
+ const holder = joinHolders(door.sub ?? (owner ? 'o' : null), await accountSub(request, env));
639
+ const ticket = holder ? await ticketFor(env, game, holder) : null;
640
+ if (screen) {
641
+ await countVisit(request, env, ctx, game, 'screen');
642
+ // The big screen picks its room now, so the QR it shows puts every phone in that same room.
643
+ let room = asked;
644
+ if (!room) {
645
+ try { room = (await (await lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' })).json()).room ?? null; } catch { room = null; }
646
+ }
647
+ const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
648
+ let qr = null;
649
+ try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
650
+ return playPage(cat, meta, { screen: true, joinUrl, qr, room, ticket, owner, launch });
566
651
  }
567
- const joinUrl = `${url.origin}/${game}/play${room ? `?room=${encodeURIComponent(room)}` : ''}`;
568
- let qr = null;
569
- try { qr = qrSvg(joinUrl, { title: `Join ${meta.name ?? game}` }); } catch { /* too long for a QR: the address shows as text */ }
570
- return playPage(cat, meta, { screen: true, joinUrl, qr, room });
571
- }
572
- if (sub === 'play') {
573
- const asked = url.searchParams.get('room');
574
- if (asked !== null && !ROOM_ID.test(asked)) return badRoomPage(cat, meta, asked);
575
652
  await countVisit(request, env, ctx, game, 'play');
576
653
  shareDaily(cat, url, ctx);
577
- return playPage(cat, meta);
654
+ return playPage(cat, meta, { ticket, owner, launch });
655
+ }
656
+ if (sub === 'api/lobby') {
657
+ if (!door.ok) return json({ ok: false, error: 'not-found' }, 404);
658
+ // A browser held out of a room asks for any other (`not`), so the Lobby never sends it back there.
659
+ const not = String(url.searchParams.get('not') ?? '').split(',').filter((r) => ROOM_ID.test(r)).slice(0, 4).join(',');
660
+ return lobby().fetch(`https://lobby/join?max=${max}${not ? `&not=${encodeURIComponent(not)}` : ''}`, { method: 'POST' });
578
661
  }
579
- if (sub === 'api/lobby') return lobby().fetch(`https://lobby/join?max=${max}`, { method: 'POST' });
580
662
  if (sub === '__net' || sub === '__watch') {
581
663
  if (request.headers.get('upgrade') !== 'websocket') return new Response('websocket only', { status: 426 });
582
664
  const room = url.searchParams.get('room') || 'main';
583
665
  if (!ROOM_ID.test(room)) return new Response('bad room', { status: 400 });
666
+ // Who the ticket names: the door of a game that is not public, and in any game a signed-in player's account.
667
+ const who = url.searchParams.get('t') ? await ticketSub(env, game, url.searchParams.get('t')) : null;
668
+ if (launch !== 'public' && !(await ticketAllows(env, who, launch))) return new Response('this game is not open to you', { status: 403 });
669
+ const b = BROWSER_KEY.test(url.searchParams.get('b') ?? '') ? url.searchParams.get('b') : '';
584
670
  const stub = env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
585
- const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}`;
671
+ const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}`;
586
672
  return stub.fetch(new Request(target, request));
587
673
  }
588
674
  // The same knock, relative to the game's own page: the same answer (see notAHomie).
589
675
  if (sub === '__homie' || sub.startsWith('__homie/')) return notAHomie(request);
590
- if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') return gameDocument(request, env, url, game, meta, cat);
676
+ if (sub === '__game' || sub === '__game/' || sub === '__game/index.html') {
677
+ if (launch !== 'public' && !(await ticketAllows(env, await ticketSub(env, game, url.searchParams.get('t')), launch))) return new Response('This game is not open to you.\n', { status: 403, headers: { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' } });
678
+ return gameDocument(request, env, url, game, meta, cat);
679
+ }
591
680
  if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
592
681
  }
593
682
  return fallback(request, env, url, getCat);
@@ -628,9 +717,13 @@ export class Table {
628
717
  this.lastReport = -1;
629
718
  /** This room was counted as opened (worker/stats.mjs `room`); cleared when its seats are all forgotten. */
630
719
  this.openCounted = false;
720
+ /** The owner's holds, banner and closed door (they outlive an empty room), and the controls already used. */
721
+ this.officeSaved = null;
722
+ this.seen = new Map();
631
723
  ctx.blockConcurrencyWhile(async () => {
632
724
  this.saved = (await ctx.storage.get('net')) ?? null;
633
725
  this.recorded = (await ctx.storage.get('recorded')) ?? 0;
726
+ this.officeSaved = (await ctx.storage.get('office')) ?? null;
634
727
  });
635
728
  }
636
729
 
@@ -649,6 +742,7 @@ export class Table {
649
742
  },
650
743
  });
651
744
  if (this.saved) this.room.restore(this.saved);
745
+ if (this.officeSaved) this.room.restoreOffice(this.officeSaved);
652
746
  return this.room;
653
747
  }
654
748
 
@@ -658,10 +752,30 @@ export class Table {
658
752
  const code = url.searchParams.get('room');
659
753
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
660
754
  const room = this.roomFor(game, code, max);
755
+ // The back office (worker/office.mjs), from the studio's Worker only: the room as its owner sees it, and the
756
+ // owner's signed controls, which this room verifies before it applies one (NETPLAY.md section 15).
757
+ if (url.pathname === '/__facts') return json(room.officeFacts());
758
+ if (url.pathname === '/__office') {
759
+ const ctl = await request.json().catch(() => null);
760
+ const why = await verifyControl(this.env, ctl, { game: this.game, room: this.code }, this.seen);
761
+ if (why) return json({ ok: false, error: 'refused', why }, 403);
762
+ const res = room.control(ctl.op, ctl.args && typeof ctl.args === 'object' ? ctl.args : {});
763
+ await this.ctx.storage.put('office', room.officeSaved()).catch(() => {});
764
+ if (ctl.op === 'close' && res.ok) this.tellLobby({ closed: ctl.args?.reopen ? 0 : res.until });
765
+ this.report();
766
+ return json(res);
767
+ }
768
+ // The owner's room size: a room already open takes it from the next visitor on.
769
+ if (room.seatCap !== max) room.setSeats(max, perAddress(max));
661
770
  const [client, server] = Object.values(new WebSocketPair());
662
771
  server.accept();
772
+ const via = url.searchParams.get('via');
663
773
  const conn = {
664
774
  ip: request.headers.get('cf-connecting-ip'),
775
+ // The browser's room key (what a kick holds out), and who the play page's ticket named (o, i-<invite>, p-<player>).
776
+ browser: url.searchParams.get('b') || null,
777
+ via: via || null,
778
+ player: holders(via).find((p) => p.startsWith('p-'))?.slice(2) ?? null,
665
779
  // House QA and `homie-studio check` mark their browsers; their rooms, rounds and peaks are not the studio's numbers.
666
780
  qa: isQa(request),
667
781
  send: (text) => { try { server.send(text); } catch { /* closed */ } },
@@ -679,6 +793,8 @@ export class Table {
679
793
  const fresh = room.seats.size === 0;
680
794
  server.addEventListener('message', (e) => {
681
795
  h.onMessage(typeof e.data === 'string' ? e.data : '');
796
+ // A newcomer: the Lobby hears of the room at once, so the owner's office (and a launch change) never misses it.
797
+ if (typeof e.data === 'string' && e.data.startsWith('{"t":"hello"')) this.report();
682
798
  if (fresh && !this.openCounted && room.seats.size > 0) {
683
799
  this.openCounted = true;
684
800
  if (conn.qa) return;
@@ -699,6 +815,8 @@ export class Table {
699
815
  const room = this.room;
700
816
  if (!room) return;
701
817
  room.tick();
818
+ // A launch change the room applied by itself (after its round): its stored state says so too.
819
+ if (room.officeDirty) { room.officeDirty = false; this.ctx.storage.put('office', room.officeSaved()).catch(() => {}); }
702
820
  n += 1;
703
821
  if (n % 4 === 0) { room.tellWatchers(); this.report(); this.recordRound(); }
704
822
  if (room.seats.size === 0 && room.clients.size === 0) this.openCounted = false;
@@ -706,6 +824,14 @@ export class Table {
706
824
  }, 250);
707
825
  }
708
826
 
827
+ /** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
828
+ tellLobby(extra) {
829
+ if (!this.game || !this.code) return;
830
+ const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
831
+ this.lastReport = -1;
832
+ this.ctx.waitUntil(lobby.fetch(`https://lobby/report?game=${encodeURIComponent(this.game)}`, { method: 'POST', body: JSON.stringify({ room: this.code, players: 0, people: 0, ...extra }) }).catch(() => {}));
833
+ }
834
+
709
835
  /** Tell the Lobby how many players this room has, when it changes. */
710
836
  report() {
711
837
  const room = this.room;
@@ -757,9 +883,16 @@ export class Lobby {
757
883
  /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
758
884
  this.live = new Map();
759
885
  this.peak = { day: null, players: 0, room: 0 };
886
+ /** Every room with somebody seated, public or named, for the owner's office; and the rooms the owner closed (until when). */
887
+ this.seen = new Map();
888
+ this.closed = new Map();
760
889
  ctx.blockConcurrencyWhile(async () => {
761
890
  const saved = await ctx.storage.get('lobby');
762
- if (saved) { this.next = saved.next ?? 1; for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] }); }
891
+ if (saved) {
892
+ this.next = saved.next ?? 1;
893
+ for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, pending: [] });
894
+ for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
895
+ }
763
896
  });
764
897
  }
765
898
 
@@ -783,7 +916,7 @@ export class Lobby {
783
916
  if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
784
917
  }
785
918
 
786
- save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) }).catch(() => {}); }
919
+ save() { this.ctx.storage.put('lobby', { next: this.next, rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })), closed: [...this.closed] }).catch(() => {}); }
787
920
 
788
921
  prune(now) {
789
922
  for (const [name, r] of this.rooms) {
@@ -798,14 +931,19 @@ export class Lobby {
798
931
  this.prune(now);
799
932
  if (url.pathname === '/join') {
800
933
  const max = Math.max(1, Math.min(SEAT_MAX, Math.floor(Number(url.searchParams.get('max'))) || 8));
934
+ // A room the owner closed, or one this visitor is held out of (`not`), is never the answer.
935
+ const not = new Set(String(url.searchParams.get('not') ?? '').split(',').filter(Boolean));
936
+ for (const [name, until] of this.closed) if (until <= now) this.closed.delete(name);
801
937
  let best = null;
802
938
  for (const r of this.rooms.values()) {
939
+ if (not.has(r.name) || this.closed.has(r.name)) continue;
803
940
  const fill = r.players + r.pending.length;
804
941
  if (fill >= max) continue;
805
942
  if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
806
943
  }
807
944
  let room = best?.r;
808
945
  if (!room) {
946
+ while (not.has(`pub-${this.next}`) || this.closed.has(`pub-${this.next}`)) this.next += 1;
809
947
  room = { name: `pub-${this.next}`, players: 0, at: now, pending: [] };
810
948
  this.next += 1;
811
949
  this.rooms.set(room.name, room);
@@ -817,6 +955,17 @@ export class Lobby {
817
955
  if (url.pathname === '/report' && request.method === 'POST') {
818
956
  const body = await request.json().catch(() => ({}));
819
957
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
958
+ // The owner closed this room: matched with nobody until then (and forgotten as a public room).
959
+ if (Number.isFinite(body.closed)) {
960
+ if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
961
+ this.seen.delete(body.room);
962
+ this.save();
963
+ return json({ ok: true, closed: body.closed > now });
964
+ }
965
+ const seated = Math.max(0, Math.floor(Number(body.players) || 0));
966
+ if (seated > 0) this.seen.set(body.room, { room: body.room, players: seated, people: Math.max(0, Math.floor(Number(body.people ?? seated) || 0)), at: now });
967
+ else this.seen.delete(body.room);
968
+ if (this.closed.has(body.room)) return json({ ok: true, closed: true });
820
969
  this.notePeak(body.room, Math.max(0, Math.floor(Number(body.people ?? body.players) || 0)), url.searchParams.get('game'));
821
970
  // Only public rooms the Lobby made are matched with strangers; a named room (?room=) stays private.
822
971
  if (!this.rooms.has(body.room) && !/^pub-\d+$/.test(body.room)) return json({ ok: true, private: true });
@@ -831,6 +980,14 @@ export class Lobby {
831
980
  return json({ ok: true });
832
981
  }
833
982
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, at }) => ({ name, players, at })) });
983
+ if (url.pathname === '/office') {
984
+ // The owner's office: every room with somebody seated in the last 3 minutes, public or named, and closed ones.
985
+ for (const [name, r] of this.seen) if (now - r.at > 180_000) this.seen.delete(name);
986
+ const rooms = new Map([...this.seen.values()].map((r) => [r.room, r]));
987
+ for (const r of this.rooms.values()) if (r.players > 0 && !rooms.has(r.name)) rooms.set(r.name, { room: r.name, players: r.players, people: r.players, at: r.at });
988
+ for (const [name, until] of this.closed) if (until > now && !rooms.has(name)) rooms.set(name, { room: name, players: 0, people: 0, at: now, closedUntil: until });
989
+ return json({ rooms: [...rooms.values()].sort((a, b) => b.players - a.players).slice(0, 100) });
990
+ }
834
991
  if (url.pathname === '/now') {
835
992
  let players = 0;
836
993
  for (const [name, r] of this.live) { if (Date.now() - r.at > 180_000) this.live.delete(name); else players += r.players; }