@homie-rocks/studio 0.55.0 → 0.57.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
package/worker/lobby.mjs CHANGED
@@ -1,22 +1,25 @@
1
+ import {LobbyPeak} from './lobby-peak.mjs';
2
+ import {MatchRouter} from './match-router.mjs';
3
+ import {LOBBY_SHARDS,SHARDED_ROOM_BASE} from './lobby-shards.mjs';
4
+ import {json} from './responses.mjs';
5
+ import {lobbyStore} from './lobby-store.mjs';
1
6
  import { errorLine, versionOf } from './room-protocol.mjs';
2
7
  import { ROOM_ID } from './page-policy.mjs';
3
8
  import { SERVER_ID, pooledRoom, roomCode, roomServer } from './servers.mjs';
4
9
  import { seatCount } from './seats.mjs';
5
10
  import { counter, today } from './stats.mjs';
6
11
 
7
- const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
8
- status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
9
- });
10
-
11
- export const LOBBY_APP_VERSIONS = 8;
12
-
13
- export const LOBBY_ROOMS_MAX = 512;
12
+ import {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
13
+ export {LOBBY_APP_VERSIONS, LOBBY_ROOMS_MAX} from './lobby-limits.mjs';
14
14
 
15
15
  export class Lobby {
16
16
  constructor(ctx, env) {
17
17
  this.ctx = ctx;
18
18
  this.env = env;
19
+ this.store = lobbyStore(ctx.storage);
19
20
  this.rooms = new Map();
21
+ this.reservations=new Map();
22
+ ctx.storage.sql?.exec("CREATE TABLE IF NOT EXISTS matchmaking_reservations(id TEXT PRIMARY KEY,body TEXT NOT NULL,expires INTEGER NOT NULL)");
20
23
  /** Each pool's next room number: { public: n, <server>: n } (a store from before servers is public's). */
21
24
  this.next = { public: 1 };
22
25
  /** Every room of this game that reported players (public and named), for "playing now" and the day's peak. */
@@ -26,11 +29,16 @@ export class Lobby {
26
29
  this.seen = new Map();
27
30
  this.closed = new Map();
28
31
  ctx.blockConcurrencyWhile(async () => {
29
- const saved = await ctx.storage.get('lobby');
32
+ const saved = await this.store.load();
30
33
  if (saved) {
31
34
  this.current = saved.current ?? null;
32
35
  this.next = typeof saved.next === 'number' ? { public: saved.next } : saved.next && typeof saved.next === 'object' ? { public: 1, ...saved.next } : { public: 1 };
33
36
  for (const r of saved.rooms ?? []) this.rooms.set(r.name, { ...r, server: r.server ?? roomServer(r.name), agents: r.agents ?? 0, ai: r.ai ?? 0, ver: typeof r.ver === 'string' ? r.ver : '', pending: [], agentPending: [] });
37
+ for(const r of this.rooms.values())if(r.players>0&&r.at>Date.now()-180000)this.live.set(r.name,{players:r.players,at:r.at});
38
+ if(ctx.storage.sql)for(const row of ctx.storage.sql.exec('SELECT body,expires FROM matchmaking_reservations WHERE expires>?',Date.now()).toArray()) {
39
+ const reservation=JSON.parse(row.body),room=this.rooms.get(reservation.room);
40
+ if(room && reservation.pending!==false)room.pending.push(row.expires-20000);
41
+ }
34
42
  for (const [name, until] of saved.closed ?? []) if (until > Date.now()) this.closed.set(name, until);
35
43
  }
36
44
  });
@@ -48,15 +56,23 @@ export class Lobby {
48
56
  if (this.peak.day !== day) this.peak = { day, players: 0, room: 0 };
49
57
  let all = 0;
50
58
  for (const r of this.live.values()) all += r.players;
59
+ if(this.env.LOBBY){
60
+ const subject=game??this.game??'';if(game)this.game=game;
61
+ const target=new URL('https://lobby/peak');target.searchParams.set('game',subject);
62
+ const at=this.peakReportAt=Math.max(now,(this.peakReportAt??0)+1);
63
+ const body=JSON.stringify({shard:this.shard??0,players:all,room:players,at});
64
+ this.ctx.waitUntil(this.env.LOBBY.get(this.env.LOBBY.idFromName(subject+'/peak')).fetch(target,{method:'POST',body}).then(r=>{if(!r.ok)throw Error('peak report failed');return r.body?.cancel();}).catch(error=>console.warn('Lobby peak report failed:',error.message)));
65
+ return;
66
+ }
51
67
  const writes = [];
52
68
  if (all > this.peak.players) { this.peak.players = all; writes.push(counter(this.env, { metric: 'peak', subject: game ?? this.game ?? '', n: all, day })); }
53
69
  if (players > this.peak.room) { this.peak.room = players; writes.push(counter(this.env, { metric: 'peak-room', subject: game ?? this.game ?? '', n: players, day })); }
54
70
  if (game) this.game = game;
55
71
  const list = writes.filter(Boolean);
56
- if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(() => {}));
72
+ if (list.length) this.ctx.waitUntil(this.env.DB.batch(list).catch(error => { this.peak = {day, players:0, room:0}; console.warn('Lobby peak write will retry on report:',error.message); }));
57
73
  }
58
74
 
59
- save() { this.ctx.storage.put('lobby', { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }).catch(() => {}); }
75
+ save() { const pending=this.store.save( { current: this.current ?? null, next: this.next, rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at, ver, host }) => ({ name, players, agents, ai, server, at, ...(host ? { host } : {}), ...(ver ? { ver } : {}) })), closed: [...this.closed] }); if(pending)this.ctx.waitUntil(pending); }
60
76
 
61
77
  prune(now) {
62
78
  for (const [name, r] of this.rooms) {
@@ -68,6 +84,8 @@ export class Lobby {
68
84
 
69
85
  /** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
70
86
  async fetch(request) {
87
+ if(new URL(request.url).pathname==='/peak')return (this.peakStore??=new LobbyPeak(this.ctx,this.env)).fetch(request);
88
+ if(['/match','/match-policy'].includes(new URL(request.url).pathname))return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).fetch(request);
71
89
  if(new URL(request.url).pathname==='/__app_records') {
72
90
  const {AppRecordStreams}=await import('./app-streams.mjs');
73
91
  this.appStreams??=new AppRecordStreams(this.env,this.ctx);
@@ -79,7 +97,11 @@ export class Lobby {
79
97
  this.eventStreams??=new EventStreams(this.env,this.ctx);
80
98
  return this.eventStreams.fetch(request);
81
99
  }
82
- try { return await this.answer(request); } catch (error) {
100
+ try {
101
+ const response=await this.answer(request),url=new URL(request.url);
102
+ if(response.ok && request.method==='POST' && url.pathname==='/report' && this.env?.BACKGROUND && url.searchParams.get('game')) await this.scheduleSummary(url.searchParams.get('game'),Number(url.searchParams.get('shard'))||0);
103
+ return response;
104
+ } catch (error) {
83
105
  let path = '';
84
106
  try { path = new URL(request.url).pathname; } catch { /* not an address */ }
85
107
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-failed', game: this.game ?? null, path, error: errorLine(error) })); } catch { /* no console */ }
@@ -87,14 +109,52 @@ export class Lobby {
87
109
  }
88
110
  }
89
111
 
112
+ async scheduleSummary(game,shard=0) {
113
+ this.summaryShard=shard;
114
+ if(this.summaryGame!==game) {await this.ctx.storage.put('summary-game',{game,shard});this.summaryGame=game;}
115
+ if(!this.summaryDue) {
116
+ this.summaryDue=Date.now()+10000;
117
+ try {await this.ctx.storage.setAlarm(this.summaryDue);} catch(error) {this.summaryDue=null;throw error;}
118
+ }
119
+ }
120
+ async alarm() {
121
+ if(this.matchRouter)return this.matchRouter.alarm();
122
+ if((await this.ctx.storage.list?.({prefix:'match:',limit:1}))?.size)return (this.matchRouter??=new MatchRouter(this.ctx,this.env)).alarm();
123
+ this.summaryDue=null;
124
+ const saved=await this.ctx.storage.get('summary-game');
125
+ const game=this.summaryGame??(typeof saved==='string'?saved:saved?.game),shard=this.summaryShard??saved?.shard??0;
126
+ if(!game||!this.env.BACKGROUND)return;
127
+ const at=Date.now();
128
+ const rooms=[...this.rooms.values()].filter(r=>r.players>0&&r.at>at-180000).map(({name,players,agents,ai,server,at})=>({name,players,agents:agents??0,ai:ai??0,server:server??roomServer(name),at}));
129
+ await this.env.BACKGROUND.send({v:1,type:'lobby.snapshot',game,shard,at,rooms});
130
+ }
131
+
132
+ reservation(key,result) {
133
+ const sql=this.ctx.storage.sql,now=Date.now();
134
+ if(sql){
135
+ if(result){sql.exec('DELETE FROM matchmaking_reservations WHERE expires<=?',now);sql.exec('INSERT OR REPLACE INTO matchmaking_reservations VALUES(?,?,?)',key,JSON.stringify({...result,pending:true}),now+20000);return;}
136
+ const row=sql.exec('SELECT body FROM matchmaking_reservations WHERE id=? AND expires>?',key,now).toArray()[0];if(!row)return null;const {pending,...saved}=JSON.parse(row.body);return saved;
137
+ }
138
+ for(const [id,row] of this.reservations)if(row.expires<=now)this.reservations.delete(id);
139
+ if(result)this.reservations.set(key,{result,expires:now+20000});
140
+ else return this.reservations.get(key)?.result;
141
+ }
142
+
90
143
  async answer(request) {
91
144
  const url = new URL(request.url);
92
145
  const now = Date.now();
146
+ this.shard=Number(url.searchParams.get('shard'))||0;
93
147
  this.prune(now);
94
148
  // REVISIONS (section 23): rooms are matched within one build of the game. A room that never said its build is
95
149
  // the build of a game that names none ('').
96
150
  const ver = versionOf(url.searchParams.get('ver')) ?? '';
97
151
  if (url.pathname === '/join') {
152
+ const shard=url.searchParams.has('shard')?Number(url.searchParams.get('shard')):null;
153
+ if(shard!==null&&(!Number.isInteger(shard)||shard<0||shard>=LOBBY_SHARDS))return json({error:'shard'},400);
154
+ const identity=url.searchParams.get('reservation');
155
+ const reservation=identity?JSON.stringify([url.searchParams.get('server'),ver,url.searchParams.get('host'),identity.slice(0,128)]):null;
156
+ const prior=reservation&&this.reservation(reservation);
157
+ if(prior && this.rooms.has(prior.room) && !this.closed.has(prior.room) && !(url.searchParams.get('not')??'').split(',').includes(prior.room))return json(prior);
98
158
  // A server build can run only its current rules. Persisted browser rooms
99
159
  // (including pre-kind records) and old server revisions must not consume
100
160
  // the pool or become the fallback of a server with roomsMax=1.
@@ -142,6 +202,7 @@ export class Lobby {
142
202
  if (fill >= max) continue;
143
203
  if (!best || fill > best.fill || (fill === best.fill && r.name < best.r.name)) best = { r, fill };
144
204
  }
205
+ if(url.searchParams.get('peek')==='1')return json({room:best?.r.name??null});
145
206
  let room = best?.r;
146
207
  let full = false;
147
208
  const fullest = (list) => list.reduce((a, b) => (b.players + b.pending.length > a.players + a.pending.length ? b : a), list[0]);
@@ -166,22 +227,26 @@ export class Lobby {
166
227
  }
167
228
  }
168
229
  if (!room) {
169
- let n = this.next[server] ?? 1;
170
- while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += 1;
230
+ const step=server==='public'&&shard!==null?LOBBY_SHARDS:1;
231
+ let n = Math.max(this.next[server] ?? 1,step>1?SHARDED_ROOM_BASE+shard:1);
232
+ while (not.has(roomCode(server, n)) || this.closed.has(roomCode(server, n)) || this.rooms.has(roomCode(server, n))) n += step;
171
233
  room = { name: roomCode(server, n), server, players: 0, agents: 0, ai: 0, at: now, ver, ...(host ? { host } : {}), pending: [], agentPending: [] };
172
- this.next[server] = n + 1;
234
+ this.next[server] = n + step;
173
235
  this.rooms.set(room.name, room);
174
236
  }
175
237
  room.pending.push(now);
176
238
  this.save();
177
- return json({ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) });
239
+ const result={ room: room.name, players: room.players, max, server, ...(full ? { full: true } : {}) };
240
+ if(reservation)this.reservation(reservation,result);
241
+ return json(result);
178
242
  }
179
243
  if (url.pathname === '/report' && request.method === 'POST') {
180
244
  const body = await request.json().catch(() => ({}));
181
245
  if (typeof body.room !== 'string' || !ROOM_ID.test(body.room)) return json({ ok: false }, 400);
182
- if (body.ended === true) { this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
246
+ if (body.ended === true) { this.notePeak(body.room,0,url.searchParams.get('game')); this.rooms.delete(body.room); this.seen.delete(body.room); this.save(); return json({ ok: true }); }
183
247
  // The owner closed this room: matched with nobody until then (and forgotten as a public room).
184
248
  if (Number.isFinite(body.closed)) {
249
+ if(body.closed>now)this.notePeak(body.room,0,url.searchParams.get('game'));
185
250
  if (body.closed > now) { this.closed.set(body.room, body.closed); this.rooms.delete(body.room); } else this.closed.delete(body.room);
186
251
  this.seen.delete(body.room);
187
252
  this.save();
@@ -205,17 +270,20 @@ export class Lobby {
205
270
  const grew = Math.max(0, seated - r.players);
206
271
  // The room says which build it runs (it changes only when nobody on the old one is left).
207
272
  const rver = typeof body.ver === 'string' ? versionOf(body.ver) ?? '' : r.ver ?? '';
208
- const changed = !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
273
+ const changed = now-r.at>=60000 || !this.rooms.has(r.name) || r.players !== seated || (r.agents ?? 0) !== agents || (r.ai ?? 0) !== ai || (r.ver ?? '') !== rver;
209
274
  r.ver = rver;
210
275
  if (body.host === 'server' || body.host === 'browser') r.host = body.host;
211
276
  r.pending.splice(0, grew);
277
+ // Retain retry ownership while retiring fulfilled seat holds. Otherwise
278
+ // eviction restores those holds on top of the already reported players.
279
+ if(grew && this.ctx.storage.sql)this.ctx.storage.sql.exec("UPDATE matchmaking_reservations SET body=json_set(body,'$.pending',json('false')) WHERE id IN (SELECT id FROM matchmaking_reservations WHERE json_extract(body,'$.room')=? AND json_extract(body,'$.pending') IS NOT 0 ORDER BY expires,id LIMIT ?)",body.room,grew);
212
280
  if ((r.agents ?? 0) < agents) (r.agentPending ?? []).splice(0, agents - (r.agents ?? 0));
213
281
  r.players = seated;
214
282
  r.agents = agents;
215
283
  r.ai = ai;
216
284
  r.at = now;
217
285
  this.rooms.set(r.name, r);
218
- if (changed) this.save(); // the minute's "still here" report writes nothing
286
+ if (changed) this.save(); // Refresh durable presence at most once per minute.
219
287
  return json({ ok: true });
220
288
  }
221
289
  if (url.pathname === '/rooms') return json({ rooms: [...this.rooms.values()].map(({ name, players, agents, ai, server, at }) => ({ name, players, agents: agents ?? 0, ai: ai ?? 0, server: server ?? roomServer(name), at })) });
@@ -116,7 +116,7 @@ export function recOf(r) {
116
116
  export async function historyOf(env, game, room, { days, before = null, limit = LOUNGE_LIMITS.page, now = Date.now() } = {}) {
117
117
  if (!env?.DB || !(days > 0)) return [];
118
118
  const since = now - days * DAY;
119
- await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND at < ?3').bind(game, room, since).run().catch(() => {});
119
+ await env.DB.prepare('DELETE FROM chat_history WHERE game = ?1 AND room = ?2 AND at < ?3').bind(game, room, since).run().catch(error => console.warn("Lounge retention deferred:",error.message));
120
120
  const n = Math.max(1, Math.min(LOUNGE_LIMITS.page, Math.floor(Number(limit) || LOUNGE_LIMITS.page)));
121
121
  const q = Number.isFinite(Number(before)) && before !== null
122
122
  ? env.DB.prepare('SELECT * FROM chat_history WHERE game = ?1 AND room = ?2 AND at >= ?3 AND at < ?4 ORDER BY at DESC LIMIT ?5').bind(game, room, since, Number(before), n)
@@ -192,7 +192,7 @@ export async function removeNight(env, id) {
192
192
  export async function nightsOf(env, { now = Date.now(), limit = 6 } = {}) {
193
193
  if (!env?.DB) return [];
194
194
  try {
195
- await env.DB.prepare('DELETE FROM lounge_nights WHERE starts_at + minutes * 60000 < ?1').bind(now - DAY).run().catch(() => {});
195
+ await env.DB.prepare('DELETE FROM lounge_nights WHERE starts_at + minutes * 60000 < ?1').bind(now - DAY).run().catch(error => console.warn("Lounge retention deferred:",error.message));
196
196
  const { results } = await env.DB.prepare('SELECT * FROM lounge_nights WHERE starts_at + minutes * 60000 >= ?1 ORDER BY starts_at ASC LIMIT ?2').bind(now, Math.max(1, Math.min(LOUNGE_LIMITS.nights, limit))).all();
197
197
  return (results ?? []).map((r) => ({ id: r.id, title: r.title, at: Number(r.starts_at), minutes: Number(r.minutes), note: r.note ?? null, game: r.game ?? null, on: Number(r.starts_at) <= now }));
198
198
  } catch { return []; }
@@ -0,0 +1,74 @@
1
+ import {lobbyShard,LOBBY_SHARDS} from './lobby-shards.mjs';
2
+
3
+ // Entry objects partition reservation ownership. They lease 8 routing decisions
4
+ // at a time, so the policy object sees 1/8 of join traffic, never room reports.
5
+ // A quiet game refills existing pools before opening shard zero. Spread above
6
+ // 128 observed joins/s: below half the slowest single-coordinator throughput
7
+ // measured by the deployed fixture (1024 durable joins, 32 concurrent).
8
+ // Count consumed leases, not all eight slots in a newly issued lease.
9
+ export class MatchRouter {
10
+ constructor(ctx,env) { this.ctx=ctx;this.env=env;this.pending=new Map();this.routes=new Map(); }
11
+ async fetch(request) {
12
+ const url=new URL(request.url),now=Date.now();
13
+ if(url.pathname==='/match-policy') {
14
+ this.demand=(this.demand??[]).filter(row=>now-row.at<1000);
15
+ this.demand.push({at:now,joins:Math.max(1,Math.min(8,Number(url.searchParams.get('used'))||1))});
16
+ return Response.json({wide:this.demand.reduce((n,row)=>n+row.joins,0)>128,until:now+1000});
17
+ }
18
+ const identity=url.searchParams.get('reservation');
19
+ const key=JSON.stringify([identity,url.searchParams.get('ver'),url.searchParams.get('host'),url.searchParams.get('not')]);
20
+ if(this.pending.has(key))return Response.json(await this.pending.get(key));
21
+ const task=this.join(url,key,now);this.pending.set(key,task);
22
+ try{return Response.json(await task);}finally{this.pending.delete(key);}
23
+ }
24
+ async join(url,key,now) {
25
+ const game=url.searchParams.get('game');
26
+ for(const [id,row] of this.routes)if(row.until<=now)this.routes.delete(id);
27
+ let route=this.routes.get(key)??await this.ctx.storage.get('match:'+key);
28
+ if(!route||route.until<=now) {
29
+ // Serialize lease consumption, not requests to the matching shards.
30
+ const previous=this.leasing??Promise.resolve();
31
+ const lease=previous.then(async()=>{
32
+ if(!this.policy||this.policy.until<=now||!this.remaining){
33
+ const target=new URL('https://lobby/match-policy');target.searchParams.set('game',game);target.searchParams.set('used',String(this.policy?8-this.remaining:1));
34
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(game+'/match-policy')).fetch(target);
35
+ if(!response.ok)throw Error('Matching policy unavailable');
36
+ this.policy=await response.json();this.remaining=8;
37
+ }
38
+ this.remaining--;
39
+ return this.policy.wide?lobbyShard(url.searchParams.get('reservation')):null;
40
+ });
41
+ this.leasing=lease.then(()=>{},()=>{});
42
+ let shard=await lease;
43
+ if(shard===null) {
44
+ // Read-only discovery: persist ownership before reserving a seat. Quiet
45
+ // joins may refill any pool left open by a burst, including after eviction.
46
+ shard=0;
47
+ for(let candidate=0;candidate<LOBBY_SHARDS;candidate++){
48
+ const target=new URL(url);target.pathname='/join';target.searchParams.set('peek','1');target.searchParams.set('shard',String(candidate));
49
+ const name=candidate?`${game}/shard/${candidate}`:game;
50
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(target);
51
+ if(!response.ok)throw Error('Matching discovery unavailable');
52
+ if((await response.json()).room){shard=candidate;break;}
53
+ }
54
+ }
55
+ route={shard,until:now+20000};
56
+ // Persist ownership before forwarding: a lost response or entry eviction
57
+ // must retry the same shard, even while the game changes routing policy.
58
+ await this.ctx.storage.put('match:'+key,route);this.routes.set(key,route);
59
+ await this.ctx.storage.setAlarm?.(now+20000);
60
+ }
61
+ url.pathname='/join';url.searchParams.set('shard',String(route.shard));
62
+ const name=route.shard?`${game}/shard/${route.shard}`:game;
63
+ const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(url);
64
+ if(!response.ok)throw Error('Matching shard unavailable');
65
+ return response.json();
66
+ }
67
+ async alarm() {
68
+ const now=Date.now();let next=Infinity;
69
+ for(const [key,row] of await this.ctx.storage.list({prefix:'match:'})) {
70
+ if(row.until<=now)await this.ctx.storage.delete(key);else next=Math.min(next,row.until);
71
+ }
72
+ if(Number.isFinite(next))await this.ctx.storage.setAlarm(next);
73
+ }
74
+ }
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {signControl,settingsOf,launchOf,seatsFor} from './office.mjs';
2
3
  import {appAccess} from './app-records.mjs';
3
4
  import {withToolIdentity} from './tool-identity.mjs';
@@ -23,7 +24,7 @@ export async function remoteSeat(env,cat,caller,op,args) {
23
24
  if(refusal)throw new Error(refusal.message);
24
25
  let room=args.room;
25
26
  if(!room&&op==='sit'){
26
- const joined=await env.LOBBY.get(env.LOBBY.idFromName(meta.id)).fetch(`https://lobby/join?max=${max}&server=${serverId}&agent=1&ai=${policy.aiSeats+policy.guides}`,{method:'POST'});room=(await joined.json()).room;
27
+ const joined=await lobbyFor(env,meta.id).fetch(`https://lobby/join?max=${max}&server=${serverId}&agent=1&ai=${policy.aiSeats+policy.guides}`,{method:'POST'});room=(await joined.json()).room;
27
28
  }
28
29
  if(!/^[A-Za-z0-9_-]{1,32}$/.test(room??''))throw new Error('No room with people; an AI never starts a room');
29
30
  const ctl=await signControl(env,{op:'mcp-seat',game:meta.id,room,args:{...args,operation:op,connection:caller.connection,person:caller.id,name:caller.name??'Studio AI'}});
@@ -42,7 +42,7 @@ export async function rateLimit(env, caller, limit = null) {
42
42
  return row.n <= limit;
43
43
  }
44
44
  export async function audit(env, caller, tool, outcome) {
45
- await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.id ?? null,caller.client ?? null,tool,Date.now(),outcome).run();
45
+ await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.studio?(caller.actingFor?.assertedHandle??null):caller.id ?? null,caller.studio?.origin??caller.client ?? null,tool,Date.now(),outcome).run();
46
46
  // Bounded storage, and no cleanup timer or writes on ordinary page traffic.
47
47
  await env.DB.batch([
48
48
  env.DB.prepare('DELETE FROM mcp_audit WHERE id <= (SELECT MAX(id)-10000 FROM mcp_audit)'),
@@ -55,3 +55,5 @@ export function callsPerMinute(cat) {
55
55
  const value=cat.studio?.mcp?.callsPerMinute;
56
56
  return Number.isSafeInteger(value)&&value>0?value:null;
57
57
  }
58
+
59
+ export const callerLimitKey=caller=>caller.studio?'studio:'+caller.studio.origin:caller.id??caller.clientAddressKey;
@@ -1,3 +1,6 @@
1
+ import {callerLimitKey} from './mcp-store.mjs';
2
+ import {bookingAvailability} from './booking-availability.mjs';
3
+ import {studiosClient} from './studios-client.mjs';
1
4
  import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
2
5
  import {bookingHolds} from './booking-holds.mjs';
3
6
  import {toolDatabase} from './tool-database.mjs';
@@ -12,7 +15,7 @@ export const object = (properties = {}, required = []) => ({type:'object',proper
12
15
  const str = {type:'string',maxLength:128};
13
16
  export async function allowed(audience, caller, env) {
14
17
  if (audience === 'public') return true;
15
- if (!caller.id) return false;
18
+ if (!caller.id || caller.studio) return false;
16
19
  if (audience === 'signed-in') return true;
17
20
  if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
18
21
  if (audience === 'owner') return caller.owner === true;
@@ -70,16 +73,37 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
70
73
  const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
71
74
  return Object.freeze({
72
75
  caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
73
- bookings:bookingHolds(env,caller,cat),
76
+ studios:studiosClient(env,cat,definition,{origin}),
77
+ bookings:Object.freeze({...bookingHolds(env,caller,cat),availability:args=>bookingAvailability(env,cat,caller,args)}),
74
78
  events:Object.freeze({publish:args=>import('./business-events.mjs').then(m=>m.publishBusinessEvent(env,caller,definition,namespace,args))}),
75
79
  rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
76
80
  shop:()=>office('/_studio/api/shop'),
77
81
  database:toolDatabase(env.DB,namespace),
78
82
  fetch:globalThis.fetch.bind(globalThis),
83
+ async fetchSelf(path,init={}) {
84
+ const url=new URL(path,origin);
85
+ if(url.origin!==new URL(origin).origin || url.username || url.password)throw new Error('Same origin required');
86
+ const headers=new Headers(init.headers);
87
+ if(headers.has('authorization')||headers.has('cookie'))throw new Error('Explicit credentials refused');
88
+ headers.set('origin',new URL(origin).origin);
89
+ const request=withToolIdentity(new Request(url,{...init,headers,redirect:'error'}),caller);
90
+ const pending=[];
91
+ const response=await (await import('./custom-routes.mjs')).customRoute(request,env,{waitUntil:promise=>pending.push(promise)});
92
+ await Promise.all(pending);
93
+ return response??new Response('Not found',{status:404});
94
+ },
95
+ async rateLimit(name,key=callerLimitKey(caller)) {
96
+ const rule=cat.studio?.mcp?.rateLimits?.[name];
97
+ if(!rule?.tools?.includes(definition.name)||!env[rule.binding]?.limit)throw new Error('Rate limit not declared for this tool');
98
+ if(typeof key!=='string'||!key.length||key.length>256)throw new Error('Rate limit key required (1–256 characters)');
99
+ return env[rule.binding].limit({key:JSON.stringify([name,key])});
100
+ },
79
101
  // A named secret may authenticate an HTTPS request; its value is never returned.
80
102
  async fetchWithSecret(name,url,init={}) {
81
- const rule=cat.studio?.mcp?.secrets?.[name]; const target=new URL(url);
82
- if(!rule || target.protocol!=='https:' || target.origin!==rule.origin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
103
+ const rule=cat.studio?.mcp?.secrets?.[name];
104
+ const permittedOrigin=rule?.origin==='self'?new URL(origin).origin:rule?.origin;
105
+ const target=new URL(url,rule?.origin==='self'?permittedOrigin:undefined);
106
+ if(!rule || target.protocol!=='https:' || target.username || target.password || target.origin!==permittedOrigin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
83
107
  const secret=env[name];if(typeof secret!=='string')throw new Error('secret unavailable');
84
108
  const headers=new Headers(init.headers);headers.set(rule.header??'authorization',`${rule.prefix??'Bearer '}${secret}`);
85
109
  const res=await fetch(target,{...init,headers,redirect:'error'});
@@ -176,6 +200,7 @@ export function builtins(env,cat,origin) {
176
200
  const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
177
201
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
178
202
  }),
203
+ tool('studio_availability','List available booking slots and options before studio_book. Returns schema.org Offers with exact booking arguments; availability is a snapshot, and booking rechecks capacity.','public',object({app:str,collection:str,after:str,quantity:{type:'integer',minimum:1},option:str,resource:str,addons:{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}},['app','collection']),async(args,ctx)=>ctx.bookings.availability(args)),
179
204
  tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
180
205
  const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
181
206
  if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
package/worker/mcp.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {studioOrigin} from './studios-client.mjs';
2
+ import {studioToken,studioCaller,setStudioPolicy,studioRegistration} from './studios-auth.mjs';
1
3
  import {tokenCaller,toolInScope} from './connector-token.mjs';
2
4
  import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
3
5
  import {eventsRequest} from './mcp-events.mjs';
@@ -17,7 +19,7 @@ import { players } from './players.mjs';
17
19
  import { isOwner, sameOrigin } from './office.mjs';
18
20
  import { cookieValue } from './stats.mjs';
19
21
  import { esc } from './site.mjs';
20
- import { oauthStorage, rateLimit, audit, callsPerMinute } from './mcp-store.mjs';
22
+ import { oauthStorage, rateLimit, audit, callsPerMinute, callerLimitKey } from './mcp-store.mjs';
21
23
  import { allowed, currentCaller, services, builtins } from './mcp-tools.mjs';
22
24
  const validator = new CfWorkerJsonSchemaValidator();
23
25
  const json = (data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store'}});
@@ -36,14 +38,28 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
36
38
  const url=new URL(request.url), origin=url.origin, path=url.pathname;
37
39
  if(!env.DB)return json({error:'studio database required'},503);
38
40
  if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
41
+ if(path==='/.well-known/jwks.json')return Response.json((await import('./studios-keys.mjs')).publicKeys(env),{headers:{'cache-control':'public, max-age=60'}});
42
+ if(path==='/oauth/token'||path.startsWith('/hooks/studios/')) {
43
+ if(!await rateLimit(env,'studio-ingress:'+(request.headers.get('cf-connecting-ip')??'local'),60))
44
+ return Response.json({error:'rate_limit_exceeded'},{status:429,headers:{'retry-after':'60','cache-control':'no-store'}});
45
+ }
46
+ if(path.startsWith('/hooks/studios/')) {
47
+ const cat=await catalogueOf();
48
+ const response=await (await import('./studios-events.mjs')).receiveStudioEvent(request,env,cat);
49
+ if(response.ok){const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;}
50
+ return response;
51
+ }
39
52
  if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
53
+ if(path==='/oauth/token'){const response=await studioToken(request,env,await catalogueOf());if(response)return response;}
54
+ const machine=request.headers.get('authorization')?.startsWith('Bearer homie_studio_');
40
55
  const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
41
56
  const localIdentity=await localCaller(request,env);
42
57
  const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
43
58
  const bearerIdentity=bearer?await tokenCaller(request,env):null;
44
59
  if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
45
60
  const api=async(request,env,ctx)=>{
46
- const identity=async()=>{if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
61
+ const rawIdentity=async()=>{if(machine){const fresh=await studioCaller(request,env,await catalogueOf());if(!fresh)throw new Error('Studio authorization revoked');return fresh;}if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
62
+ const identity=async()=>({...await rawIdentity(),clientAddressKey:`public:${request.headers.get('cf-connecting-ip')??'local'}`});
47
63
  let caller;
48
64
  try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
49
65
  if(caller.tools){
@@ -74,7 +90,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
74
90
  }
75
91
  const rate=async fresh=>{
76
92
  const limit=callsPerMinute(cat);
77
- if(!await rateLimit(env,fresh.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,limit))throw new Error('Rate limit reached');
93
+ if(!await rateLimit(env,callerLimitKey(fresh),limit))throw new Error('Rate limit reached');
78
94
  };
79
95
  const read=async(name,handler)=>{
80
96
  let outcome='error';
@@ -144,7 +160,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
144
160
  if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
145
161
  const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
146
162
  const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
147
- outcome='ok';return {content:[{type:'text',text}]};
163
+ outcome='ok';return {structuredContent:{kind:'untrusted-tool-data',tool:tool.name,data:value??null},content:[{type:'text',text}]};
148
164
  }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
149
165
  finally{
150
166
  try { await audit(env,caller,tool.name,outcome); }
@@ -178,6 +194,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
178
194
  if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
179
195
  const provider=new OAuthProvider({
180
196
  apiRoute:'/mcp',apiHandler:{fetch:api},
197
+ resolveExternalToken:async({request,env})=>(await studioCaller(request,env,await catalogueOf()))?{props:{},audience:origin+'/mcp'}:null,
181
198
  authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
182
199
  resourceMetadata:{resource:origin+'/mcp',authorization_servers:[origin]},
183
200
  scopesSupported:['studio','offline_access'],requiredScopes:['studio'],
@@ -209,7 +226,14 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
209
226
  const owner=await isOwner(req,env);
210
227
  if(req.method==='POST'){
211
228
  if(!sameOrigin(req,u))return json({error:'origin refused'},403);
212
- const f=await req.formData();const id=String(f.get('connection')??'');
229
+ const f=await req.formData();
230
+ if(f.has('studioOrigin')){
231
+ const studio=String(f.get('studioOrigin'));
232
+ if(!owner||!studioRegistration(await catalogueOf(),studio))return json({error:'studio policy refused'},403);
233
+ await setStudioPolicy(env,studio,f.get('allow')==='true');
234
+ return Response.redirect(origin+u.pathname,303);
235
+ }
236
+ const id=String(f.get('connection')??'');
213
237
  const connection=await env.DB.prepare('SELECT * FROM mcp_connections WHERE id=?1').bind(id).first();
214
238
  if(connection&&(owner||connection.person===person)){
215
239
  // Local denial takes effect first, even if upstream token cleanup must be retried.
@@ -224,9 +248,15 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
224
248
  }
225
249
  const page=Math.max(0,Math.min(100,Number.parseInt(u.searchParams.get('page')??'0',10)||0)),offset=page*100;
226
250
  const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
227
- const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
251
+ const events=owner?(await env.DB.prepare("SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person AND (a.client IS NULL OR a.client NOT LIKE 'https://%') ORDER BY a.id DESC LIMIT 100 OFFSET ?1").bind(offset).all()).results:[];
228
252
  const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
229
- return html(`<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
253
+ const studioRows=[];
254
+ if(owner)for(const rule of Object.values((await catalogueOf()).studio?.mcp?.studios?.inbound??{})){
255
+ let peer;try{peer=studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true});}catch{continue;}
256
+ const policy=await oauthStorage(env.DB).get('studio-policy:'+peer,'json');
257
+ studioRows.push(`<li>${esc(peer)} · ${policy?.allow?'Allowed':'Denied'}<form method="post"><input type="hidden" name="studioOrigin" value="${esc(peer)}"><input type="hidden" name="allow" value="${policy?.allow?'false':'true'}"><button>${policy?.allow?'Deny':'Allow'}</button></form></li>`);
258
+ }
259
+ return html(`${owner?'<h2>Studio connections</h2><p>Denied by default. Allow only origins you trust for their declared tools and events.</p><ul>'+studioRows.join('')+'</ul>':''}<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.client?.startsWith('https://')?'Asserted handle: '+(r.person??'(none)'):(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public'))} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
230
260
  }
231
261
  return json({error:'not found'},404);
232
262
  }},
@@ -235,5 +265,10 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
235
265
  // Public tools remain available without an account. Clients can discover OAuth metadata directly.
236
266
  return api(request,scoped,ctx);
237
267
  }
238
- return provider.fetch(request,scoped,ctx);
268
+ const response=await provider.fetch(request,scoped,ctx);
269
+ if(path==='/.well-known/oauth-authorization-server'&&response.ok){
270
+ const metadata=await response.json();
271
+ return json({...metadata,grant_types_supported:[...new Set([...(metadata.grant_types_supported??[]),'client_credentials'])],token_endpoint_auth_methods_supported:[...new Set([...(metadata.token_endpoint_auth_methods_supported??[]),'private_key_jwt'])]});
272
+ }
273
+ return response;
239
274
  }
package/worker/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {lobbyFor} from './lobby-shards.mjs';
1
2
  import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
2
3
  import { selling } from './extensions.mjs';
3
4
  import { purchaseOrderIfPresent } from './shop.mjs';
@@ -410,7 +411,7 @@ export async function redeemInvite(request, env, url, cat, meta, settings) {
410
411
 
411
412
  /* ------------------------------------------------------------------ talking to rooms */
412
413
 
413
- const lobbyOf = (env, game) => env.LOBBY.get(env.LOBBY.idFromName(game));
414
+ const lobbyOf = (env, game) => lobbyFor(env,game);
414
415
  const tableOf = (env, game, room) => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
415
416
 
416
417
  /** The rooms of one game with people in them now, as its Lobby last heard (public and named). */
@@ -36,6 +36,8 @@ CREATE INDEX IF NOT EXISTS mcp_kv_expires ON mcp_kv(expires) WHERE expires IS NO
36
36
  `;
37
37
 
38
38
  export const EXTRA_RUNTIME_MIGRATIONS={
39
+ "0036_lobby_summaries.sql":`CREATE TABLE IF NOT EXISTS lobby_summaries(game TEXT PRIMARY KEY,rooms TEXT NOT NULL,updated INTEGER NOT NULL);
40
+ `,
39
41
  "0035_payment_delivery.sql":`CREATE TABLE IF NOT EXISTS stripe_intake(id TEXT PRIMARY KEY,payload TEXT NOT NULL,origin TEXT NOT NULL,received INTEGER NOT NULL,lease INTEGER NOT NULL DEFAULT 0,done INTEGER);
40
42
  CREATE INDEX IF NOT EXISTS stripe_intake_pending ON stripe_intake(lease,received,id) WHERE done IS NULL;
41
43
  CREATE INDEX IF NOT EXISTS stripe_intake_done ON stripe_intake(done) WHERE done IS NOT NULL;
package/worker/pages.mjs CHANGED
@@ -983,7 +983,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
983
983
  try { arrived = performance.getEntriesByType('navigation')[0].type; } catch (e) {}
984
984
  askedRoom = kept && (arrived === 'reload' || arrived === 'back_forward') && EMBED_ROOM.test(kept.room || '') ? kept.room : null;
985
985
  }
986
- var lobbyQ = [];
986
+ var lobbyQ = ['b=' + encodeURIComponent(roomKey)];
987
987
  if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
988
988
  if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
989
989
  // Once the game has the screen, the chip says the room's facts for a few seconds, then gets out of the way.