@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.
- package/CHANGELOG.md +72 -0
- package/README.md +2 -0
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/docs/studio-to-studio.md +248 -0
- package/functions/FUNCTIONS.md +9 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +52 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +1 -1
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +88 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +12 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +22 -1
- package/tools/tools.ts +14 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/booking-availability.mjs +35 -0
- package/worker/booking-holds.mjs +2 -2
- package/worker/booking-resources.mjs +14 -6
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +2 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +29 -4
- package/worker/mcp.mjs +43 -8
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +45 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +8 -5
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/studios-auth.mjs +71 -0
- package/worker/studios-client.mjs +93 -0
- package/worker/studios-events.mjs +52 -0
- package/worker/studios-keys.mjs +54 -0
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- 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
|
-
|
|
8
|
-
|
|
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
|
|
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.
|
|
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 {
|
|
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
|
-
|
|
170
|
-
|
|
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 +
|
|
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
|
-
|
|
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(); //
|
|
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 })) });
|
package/worker/lounge-store.mjs
CHANGED
|
@@ -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
|
+
}
|
package/worker/mcp-room.mjs
CHANGED
|
@@ -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
|
|
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'}});
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -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;
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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];
|
|
82
|
-
|
|
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
|
|
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
|
|
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();
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
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.
|