@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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
2
|
+
// Retry transport failures in order. Queue limits still bound memory during outages.
|
|
3
|
+
export async function retryRelay(work, alive = () => true, wait = sleep) {
|
|
4
|
+
for (let attempt = 0; alive(); attempt++) {
|
|
5
|
+
try { return await work(); } catch (error) {
|
|
6
|
+
if (!alive()) return;
|
|
7
|
+
await wait(Math.min(5000, 100 * 2 ** Math.min(attempt, 6)));
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Ordered input batches over bindings. A completed request holds no connection;
|
|
13
|
+
* output travels on a separate stream, never a recursive callback chain. */
|
|
14
|
+
export function relayChannel(ctx, deliver, {sleep: wait = sleep} = {}) {
|
|
15
|
+
const listeners = new Map();
|
|
16
|
+
let chain = Promise.resolve(), closed = false, pending = 0, pendingBytes = 0, live = null, sequence = 0;
|
|
17
|
+
const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
|
|
18
|
+
return {
|
|
19
|
+
accept() {},
|
|
20
|
+
addEventListener(name, fn) { const list = listeners.get(name) ?? []; list.push(fn); listeners.set(name, list); },
|
|
21
|
+
emit,
|
|
22
|
+
async attachLive(socket) {
|
|
23
|
+
socket.addEventListener('message',event=>emit('message',event));
|
|
24
|
+
socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket){live=null;emit('detached',{});}});
|
|
25
|
+
socket.addEventListener('error',()=>{if(live===socket){live=null;emit('detached',{});}});
|
|
26
|
+
await chain;
|
|
27
|
+
if(closed){socket.close(1000,'closed');return;}
|
|
28
|
+
if(socket.readyState!==1)return;
|
|
29
|
+
live=socket;
|
|
30
|
+
emit('drain',{});
|
|
31
|
+
},
|
|
32
|
+
isLive:()=>Boolean(live),
|
|
33
|
+
canSend(size) { return Boolean(live) || pendingBytes+size<=4*1024*1024; },
|
|
34
|
+
send(data) {
|
|
35
|
+
if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
|
|
36
|
+
if (closed) throw new Error('Relay channel closed');
|
|
37
|
+
const size=data.length;
|
|
38
|
+
if (pendingBytes+size>4*1024*1024) throw new Error('Relay channel queue limit');
|
|
39
|
+
pending++;pendingBytes+=size;
|
|
40
|
+
const id = ++sequence;
|
|
41
|
+
chain = chain.then(async () => {
|
|
42
|
+
if (closed) return;
|
|
43
|
+
await retryRelay(async () => {
|
|
44
|
+
const response = await deliver(data, id);
|
|
45
|
+
await response.body?.cancel();
|
|
46
|
+
if (!response.ok) throw new Error('Relay delivery failed: ' + response.status);
|
|
47
|
+
}, () => !closed, wait);
|
|
48
|
+
}).catch(error => { if (!closed) { closed = true; emit('error', {error}); } }).finally(() => { pending--;pendingBytes-=size;emit('drain',{}); });
|
|
49
|
+
ctx.waitUntil(chain);
|
|
50
|
+
},
|
|
51
|
+
close(code, reason) { if (!closed) { closed = true;live?.close(code,reason);live=null;emit('close', {code, reason}); } },
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A Table's output is ordered on one stream while active. Closing the stream
|
|
56
|
+
* detaches transport, not logical players. An alarm or a player's next message
|
|
57
|
+
* can request another stream without sending a new welcome. */
|
|
58
|
+
export function relayStream(ctx, wake, {sleep: wait = sleep} = {}) {
|
|
59
|
+
const listeners = new Map(), encoder = new TextEncoder();
|
|
60
|
+
const limit = 4 * 1024 * 1024;
|
|
61
|
+
let controller = null, live = null, queued = [], bytes = 0, waking = false, closed = false;
|
|
62
|
+
const emit = (name, event) => { for (const fn of listeners.get(name) ?? []) fn(event); };
|
|
63
|
+
const request = () => {
|
|
64
|
+
if (waking || closed) return;
|
|
65
|
+
waking = true;
|
|
66
|
+
ctx.waitUntil(retryRelay(wake, () => !closed && waking, wait));
|
|
67
|
+
};
|
|
68
|
+
return {
|
|
69
|
+
accept() {}, emit,
|
|
70
|
+
canSend(size, data) {
|
|
71
|
+
const needed=data===undefined?size*3+1:encoder.encode(data+'\n').byteLength;
|
|
72
|
+
return !closed && (Boolean(live) || needed+(controller?limit-controller.desiredSize:bytes)<=limit);
|
|
73
|
+
},
|
|
74
|
+
addEventListener(name, fn) { const list=listeners.get(name)??[];list.push(fn);listeners.set(name,list); },
|
|
75
|
+
send(data) {
|
|
76
|
+
if(live){try{if(live.readyState===1){live.send(data);return;}}catch{ /* Retry over the idle transport. */ }live=null;}
|
|
77
|
+
const chunk = encoder.encode(data+'\n');
|
|
78
|
+
if (chunk.byteLength + (controller ? limit-controller.desiredSize : bytes) > limit) throw new Error('Relay output queue limit');
|
|
79
|
+
if (controller) controller.enqueue(chunk);
|
|
80
|
+
else { queued.push(chunk);bytes+=chunk.byteLength;request(); }
|
|
81
|
+
},
|
|
82
|
+
attachLive(socket) {
|
|
83
|
+
controller?.close();controller=null;live?.close(1000,'replaced');live=socket;waking=false;emit('drain',{});
|
|
84
|
+
for(const chunk of queued)socket.send(new TextDecoder().decode(chunk).trimEnd());queued=[];bytes=0;
|
|
85
|
+
socket.addEventListener('close',()=>{try{socket.close(1000,'detached');}catch{}if(live===socket)live=null;});
|
|
86
|
+
socket.addEventListener('error',()=>{if(live===socket)live=null;});
|
|
87
|
+
},
|
|
88
|
+
open() {
|
|
89
|
+
controller?.close();
|
|
90
|
+
let owned;
|
|
91
|
+
return new ReadableStream({
|
|
92
|
+
start(next) { owned=next;controller=next;waking=false;for(const chunk of queued)next.enqueue(chunk);queued=[];bytes=0; },
|
|
93
|
+
pull() { emit('drain',{}); },
|
|
94
|
+
cancel() { if(controller===owned)controller=null; },
|
|
95
|
+
},{highWaterMark:limit,size:chunk=>chunk.byteLength});
|
|
96
|
+
},
|
|
97
|
+
sleep() { controller?.close();controller=null;const socket=live;live=null;socket?.close(1000,'room idle'); },
|
|
98
|
+
close(code,reason) {
|
|
99
|
+
if(closed)return;closed=true;
|
|
100
|
+
if(live){try{live.send(JSON.stringify([['link-close',code,reason]]));}catch{}live.close(code,reason);live=null;}
|
|
101
|
+
if(controller){controller.enqueue(encoder.encode(JSON.stringify([['link-close',code,reason]])+'\n'));controller.close();}
|
|
102
|
+
controller=null;queued=[];bytes=0;emit('close',{code,reason});
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export async function readRelay(response, socket) {
|
|
108
|
+
if (!response.ok || !response.body) throw new Error('Relay stream refused');
|
|
109
|
+
const reader=response.body.getReader(),decoder=new TextDecoder();let pending='';
|
|
110
|
+
try {
|
|
111
|
+
for (;;) {
|
|
112
|
+
const {value,done}=await reader.read();if(done)break;
|
|
113
|
+
pending+=decoder.decode(value,{stream:true});
|
|
114
|
+
let end;while((end=pending.indexOf('\n'))>=0){if(end>4*1024*1024)throw new Error('Relay frame limit');const data=pending.slice(0,end);pending=pending.slice(end+1);socket.emit('message',{data});}
|
|
115
|
+
if(pending.length>4*1024*1024)throw new Error('Relay frame limit');
|
|
116
|
+
}
|
|
117
|
+
if(pending)throw new Error('Incomplete relay frame');
|
|
118
|
+
} finally { await reader.cancel().catch(()=>{}); }
|
|
119
|
+
}
|
package/worker/request-db.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {LIVE_ROOMS_QUERY} from './lobby-summary.mjs';
|
|
1
2
|
export const METADATA_REVISION=Symbol('metadata revision');
|
|
2
3
|
export const ORIGINAL_DB=Symbol('original database');
|
|
3
4
|
export const MUTATIONS=Symbol('request mutations');
|
|
@@ -15,7 +16,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
|
|
|
15
16
|
// any bookmark returned by an earlier request without trusting stale auth.
|
|
16
17
|
if(DB===binding&&typeof DB.withSession==='function')DB=DB.withSession('first-primary');
|
|
17
18
|
let revision,records,rows,liveRooms;
|
|
18
|
-
const prefetchRooms=
|
|
19
|
+
const prefetchRooms=state.summaries&&/^\/(?:api\/(?:rooms|games)|\.well-known\/homie-studio\.json)?\/?$/.test(new URL(request.url).pathname);
|
|
19
20
|
const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
|
|
20
21
|
const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
|
|
21
22
|
const stamp=()=>revision??=(async()=>{
|
|
@@ -27,7 +28,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
|
|
|
27
28
|
const cold=!cached;
|
|
28
29
|
const statements=[query,...(cold?queries.map(sql=>DB.prepare(sql)):[])];
|
|
29
30
|
if(recordQuery)statements.push(DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined)));
|
|
30
|
-
if(prefetchRooms)statements.push(DB.prepare(
|
|
31
|
+
if(prefetchRooms)statements.push(DB.prepare(LIVE_ROOMS_QUERY));
|
|
31
32
|
const result=await DB.batch(statements);
|
|
32
33
|
if(prefetchRooms)liveRooms=result.at(-1);
|
|
33
34
|
let version=result[0].results?.[0]?.value??null;
|
|
@@ -46,7 +47,7 @@ export function requestDatabase(DB, request, state={mutated:false}, binding=data
|
|
|
46
47
|
const prepare=(sql,args=[])=>{
|
|
47
48
|
const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
|
|
48
49
|
const read=async()=>{
|
|
49
|
-
if(sql===
|
|
50
|
+
if(sql===LIVE_ROOMS_QUERY&&prefetchRooms){await stamp();return liveRooms;}
|
|
50
51
|
if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
|
|
51
52
|
if(!metadataQueries.has(sql))return original().all();
|
|
52
53
|
await stamp();
|
package/worker/room-chat.mjs
CHANGED
|
@@ -130,7 +130,7 @@ onChat({ client = null, conn = null }, m) {
|
|
|
130
130
|
if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
131
131
|
this.room.chatPending += 1;
|
|
132
132
|
const t0 = now;
|
|
133
|
-
Promise.resolve()
|
|
133
|
+
const reviewing=Promise.resolve()
|
|
134
134
|
.then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
|
|
135
135
|
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
136
136
|
.then((v) => {
|
|
@@ -150,6 +150,7 @@ onChat({ client = null, conn = null }, m) {
|
|
|
150
150
|
if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
151
151
|
this.room.publishChat(rec, who.conn, n);
|
|
152
152
|
});
|
|
153
|
+
this.room.waitUntil?.(reviewing);
|
|
153
154
|
return { ok: true, pending: true };
|
|
154
155
|
}
|
|
155
156
|
return this.room.publishChat(rec, who.conn, n);
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** Hibernation keeps transports outside JavaScript. Only data crosses this
|
|
2
|
+
* boundary; callbacks, bindings and sockets are reconstructed by the Table.
|
|
3
|
+
*/
|
|
4
|
+
// Only protocol identity, admission, controls and round deadlines survive here.
|
|
5
|
+
// World snapshots/checkpoints belong to the existing host/room save, not sessions.
|
|
6
|
+
const fields = new Set(`rawVocab vocab snapAt rates agentsAloneMs voteMs voteCooldownMs autoVoteMs policy level levelBy caps vote lastVoteOpen lastAutoVote lastHumanAt agentsOut officeDirty endedMatch code rules tickHz seatCap maxPlayers maxScreens perIp stallMs silentMs idleMs holdMs forgetMs persistMs gameVer currentVer occSeq seats hostId hostSince lastJoinAt lastRound lastRoster emptySince preferHost seatsDirty persistDirty lastPersistAt bans mutes announcement closedUntil closedWhy openedAt askedMax regate decideTimes defaultChat`.split(' '));
|
|
7
|
+
function urlDelta(base, value) {
|
|
8
|
+
const a=new URL(base),b=new URL(value),changes=[];
|
|
9
|
+
for(const key of new Set([...a.searchParams.keys(),...b.searchParams.keys()])) {
|
|
10
|
+
const x=a.searchParams.getAll(key),y=b.searchParams.getAll(key);
|
|
11
|
+
if(JSON.stringify(x)!==JSON.stringify(y))changes.push([key,y]);
|
|
12
|
+
}
|
|
13
|
+
return {path:b.pathname,changes};
|
|
14
|
+
}
|
|
15
|
+
function clientURL(base, delta) {
|
|
16
|
+
const url=new URL(base);url.pathname=delta.path;
|
|
17
|
+
for(const [key,values] of delta.changes){url.searchParams.delete(key);for(const value of values)url.searchParams.append(key,value);}
|
|
18
|
+
return url.toString();
|
|
19
|
+
}
|
|
20
|
+
import {isQa} from './stats.mjs';
|
|
21
|
+
const qa = agent => isQa({headers:new Headers({'user-agent':agent??''})})?'homie-house-qa':null;
|
|
22
|
+
import {encodeSession as encode,decodeSession as decode} from './session-codec.mjs';
|
|
23
|
+
const legacyDecode=value=>JSON.parse(value,(_key,item)=>item?.__roomMap?new Map(item.__roomMap):item?.__roomSet?new Set(item.__roomSet):item?.__roomNumber?item.__roomNumber*Infinity:item);
|
|
24
|
+
export function roomSessions(storage) {
|
|
25
|
+
const sql=storage.sql;let previous=new Map();
|
|
26
|
+
return {
|
|
27
|
+
read() {
|
|
28
|
+
if(!sql)return null;
|
|
29
|
+
sql.exec('CREATE TABLE IF NOT EXISTS room_sessions(key TEXT PRIMARY KEY,value TEXT NOT NULL)');
|
|
30
|
+
previous=new Map(sql.exec('SELECT key,value FROM room_sessions').toArray().map(row=>[row.key,row.value]));
|
|
31
|
+
if(!previous.has('version'))return null;
|
|
32
|
+
if(!['1','2','3'].includes(previous.get('version')))throw new Error('Unsupported room session snapshot');
|
|
33
|
+
const read=previous.get('version')==='1'?legacyDecode:decode;
|
|
34
|
+
const table=previous.has('table')?read(previous.get('table')):{};
|
|
35
|
+
if(previous.get('version')==='3')table.relayDescriptors=[...previous].filter(([key])=>key.startsWith('relay:')).map(([key,value])=>{
|
|
36
|
+
const relay=read(value),prefix='relay-client:'+key.slice(6)+':';
|
|
37
|
+
relay.clients=[...previous].filter(([key])=>key.startsWith(prefix)).map(([,value])=>{const [id,clientId,delta,ip,agent]=read(value);return [id,clientId,clientURL(relay.url,delta),ip,qa(agent)];});
|
|
38
|
+
return relay;
|
|
39
|
+
});
|
|
40
|
+
return {table,fields:new Map([...previous].filter(([key])=>key.startsWith('room:')).map(([key,value])=>[key.slice(5),read(value)])),clients:new Map([...previous].filter(([key])=>key.startsWith('client:')).map(([key,value])=>[key.slice(7),read(value)]))};
|
|
41
|
+
},
|
|
42
|
+
write(room,table = {}) {
|
|
43
|
+
if(!sql)throw new Error('Room hibernation requires SQLite storage');
|
|
44
|
+
const {relayDescriptors=[],...metadata}=table;
|
|
45
|
+
const next=new Map([['version','3'],['table',encode(metadata)]]);
|
|
46
|
+
relayDescriptors.forEach((relay,index)=>{
|
|
47
|
+
next.set('relay:'+index,encode({url:relay.url}));
|
|
48
|
+
for(const [id,clientId,url,ip,agent] of relay.clients)next.set('relay-client:'+index+':'+id,encode([id,clientId,urlDelta(relay.url,url),ip,qa(agent)]));
|
|
49
|
+
});
|
|
50
|
+
for(const [key,value] of Object.entries(room))if(fields.has(key)&&value!==undefined&&typeof value!=='function')next.set('room:'+key,encode(value));
|
|
51
|
+
for(const [id,client] of room.clients){const {conn,lastSeen,probe,readyAt,ingressRateAt,rttWindow,rttReadAt,rttToldAt,measuredRtt,...state}=client;
|
|
52
|
+
if(state.rates instanceof Map)state.rates=new Map([...state.rates].filter(([key])=>key!=='ping'&&key!=='ping:view'));next.set('client:'+id,encode(state));}
|
|
53
|
+
storage.transactionSync(()=>{
|
|
54
|
+
for(const [key,value] of next)if(previous.get(key)!==value)sql.exec('INSERT INTO room_sessions VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value',key,value);
|
|
55
|
+
for(const key of previous.keys())if(!next.has(key))sql.exec('DELETE FROM room_sessions WHERE key=?',key);
|
|
56
|
+
});
|
|
57
|
+
previous=next;
|
|
58
|
+
},
|
|
59
|
+
clear() {if(sql&&previous.size){sql.exec('DELETE FROM room_sessions');previous.clear();}},
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
export function restoreRoomFields(room,snapshot) {
|
|
63
|
+
if(!snapshot)return;
|
|
64
|
+
for(const [key,value] of snapshot.fields)if(fields.has(key)&&Object.hasOwn(room,key)&&typeof room[key]!=='function')room[key]=value;
|
|
65
|
+
}
|
package/worker/room.mjs
CHANGED
|
@@ -53,6 +53,10 @@ export class NetRoom {
|
|
|
53
53
|
this.agentsOut = null;
|
|
54
54
|
/** Revision 7: the game's agents.json (the Table reads it once), the only words an AI here may say. */
|
|
55
55
|
this.vocab = null;
|
|
56
|
+
this.rawVocab = null;
|
|
57
|
+
this.officeDirty = false;
|
|
58
|
+
this.endedMatch = null;
|
|
59
|
+
this.snapAt = new Map();
|
|
56
60
|
this.code = code;
|
|
57
61
|
this.rules = rules === true;
|
|
58
62
|
this.tickHz = tickHz;
|
|
@@ -268,6 +272,7 @@ export class NetRoom {
|
|
|
268
272
|
this.lastSnap = snap;
|
|
269
273
|
if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
|
|
270
274
|
if (c) c.lastSnapAt = now;
|
|
275
|
+
this.retryBrowserSeats(c);
|
|
271
276
|
this.stats.snaps += 1;
|
|
272
277
|
this.snapTimes.push(now);
|
|
273
278
|
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
@@ -384,6 +389,34 @@ export class NetRoom {
|
|
|
384
389
|
this.tellWatchers();
|
|
385
390
|
}
|
|
386
391
|
|
|
392
|
+
/** A browser can hear joins before its world exists. Its roster acknowledges
|
|
393
|
+
* bodies, so re-announce missing connected seats until it acknowledges them.
|
|
394
|
+
* Use the existing join protocol for games bundling older netplay clients.
|
|
395
|
+
* Snapshot traffic retries even if the boot roster was published too early;
|
|
396
|
+
* retries back off from 1 to 30 seconds and stop after eight attempts.
|
|
397
|
+
* An exhausted peer is logged once; retries are never sent to replicas.
|
|
398
|
+
*/
|
|
399
|
+
retryBrowserSeats(host) {
|
|
400
|
+
if (!host || this.server || this.rules || host.id !== this.hostId || !this.lastRoster) return;
|
|
401
|
+
const seated = new Set(this.lastRoster.filter(s => !s.bot || s.agent).flatMap(s => [s.seat, s.agent?.seat]));
|
|
402
|
+
const now = this.now();
|
|
403
|
+
for (const peer of this.live()) {
|
|
404
|
+
if (peer.id === host.id || peer.seat === null || peer.watch) continue;
|
|
405
|
+
if (seated.has(peer.seat)) { peer.seatRetry = null; continue; }
|
|
406
|
+
const retry = peer.seatRetry?.host === host.id ? peer.seatRetry : null;
|
|
407
|
+
if (retry && now - retry.at < Math.min(1000 * 2 ** (retry.tries - 1), 30000)) continue;
|
|
408
|
+
if (retry?.tries >= 8) {
|
|
409
|
+
if (!retry.exhausted) {
|
|
410
|
+
retry.exhausted = true;
|
|
411
|
+
this.log({ ev: 'seat-retry-exhausted', room: this.code, id: peer.id, seat: peer.seat, host: host.id, tries: retry.tries });
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
peer.seatRetry = { host: host.id, at: now, tries: (retry?.tries ?? 0) + 1 };
|
|
416
|
+
this.send(host, { t: 'join', peer: this.peer(peer) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
387
420
|
hostCaps(c, m) {
|
|
388
421
|
// The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
|
|
389
422
|
if (!Array.isArray(m.caps)) return;
|
|
@@ -401,7 +434,7 @@ export class NetRoom {
|
|
|
401
434
|
/* ------------------------------------------------------------ sockets */
|
|
402
435
|
|
|
403
436
|
/** A new relay socket. Returns the two callbacks the transport must call. */
|
|
404
|
-
attach(conn) {
|
|
437
|
+
attach(conn, resume = null) {
|
|
405
438
|
const now = this.now();
|
|
406
439
|
const c = {
|
|
407
440
|
id: randomId(6), conn, ip: conn.ip ?? null, browser: conn.browser ?? null, player: conn.player ?? null, via: conn.via ?? null, helloed: false, seat: null, token: null, name: '', typed: '', colour: 0,
|
|
@@ -422,6 +455,7 @@ export class NetRoom {
|
|
|
422
455
|
// address, so a helper from before revisions carries it too) when the transport gives one, else the hello's.
|
|
423
456
|
verWord: 'ver' in conn ? versionOf(conn.ver) : undefined, ver: null, feat: [], staleTold: false,
|
|
424
457
|
};
|
|
458
|
+
if(resume)Object.assign(c,resume,{conn});
|
|
425
459
|
this.clients.set(c.id, c);
|
|
426
460
|
return {
|
|
427
461
|
id: c.id,
|
|
@@ -451,18 +485,19 @@ export class NetRoom {
|
|
|
451
485
|
* The shell's watch socket: the room's facts, and (revision 6) the party's vote from the play page's card. A vote
|
|
452
486
|
* from it counts as the seat of the client in this room with the same browser key (`conn.browser`).
|
|
453
487
|
*/
|
|
454
|
-
watch(conn) {
|
|
488
|
+
watch(conn, restored = null) {
|
|
455
489
|
this.watchers.add(conn);
|
|
456
|
-
this.sendText(conn, JSON.stringify(this.facts()));
|
|
490
|
+
if (!restored) this.sendText(conn, JSON.stringify(this.facts()));
|
|
457
491
|
// A shell that comes back to a room it was kicked from (or that is closed) hears so at once.
|
|
458
492
|
const held = this.heldNotice(conn);
|
|
459
|
-
if (held) this.sendText(conn, JSON.stringify(held));
|
|
493
|
+
if (!restored && held) this.sendText(conn, JSON.stringify(held));
|
|
460
494
|
// Room chat (section 19): the last few minutes, so a page that just opened has the conversation (never to a page the
|
|
461
495
|
// studio holds out of the room, or a room it closed).
|
|
462
496
|
// A line of this browser's or this account's own says so (`mine`), on this page's copy only: it may take it down.
|
|
463
|
-
if ((!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
|
|
464
|
-
const rate = [];
|
|
497
|
+
if (!restored && (!held || held.t === 'muted') && (!conn.hub || this.chatRules().hub)) this.sendText(conn, JSON.stringify({ t: 'lines', lines: this.recentChat().map((r) => (this.ownLine(r, conn) ? { ...this.wireLine(r), mine: true } : this.wireLine(r))) }));
|
|
498
|
+
const rate = restored?.rate ?? [];
|
|
465
499
|
return {
|
|
500
|
+
state: {rate},
|
|
466
501
|
onClose: () => this.watchers.delete(conn),
|
|
467
502
|
onMessage: (text) => {
|
|
468
503
|
const now = this.now();
|
|
@@ -974,7 +1009,7 @@ export class NetRoom {
|
|
|
974
1009
|
const behind = !lite && this.currentVer !== undefined && c.ver !== this.currentVer;
|
|
975
1010
|
if (behind) c.staleTold = true;
|
|
976
1011
|
this.send(c, {
|
|
977
|
-
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
1012
|
+
t: 'welcome', v: NET_VERSION, rev: NET_REVISION, ...(c.conn.idlePing ? {idlePing:true} : {}), id: c.id, room: this.code, seat: c.seat, token: c.token, name: c.name, colour: c.colour,
|
|
978
1013
|
...(this.gameVer ? { ver: this.gameVer } : {}), ...(behind ? { stale: { ver: this.currentVer } } : {}), stall: this.stallMs,
|
|
979
1014
|
role, why, host: this.hostRef(), peers: this.peers(), ...(this.rules ? { held: this.heldPeers() } : {}), st: now, max: this.maxPlayers,
|
|
980
1015
|
round: this.lastRound, roster: this.lastRoster, snap: lite ? null : this.playerSnapshot(c, this.lastSnap, true), state: lite ? {} : this.stateObject(),
|
|
@@ -1345,18 +1380,19 @@ export class NetRoom {
|
|
|
1345
1380
|
}
|
|
1346
1381
|
|
|
1347
1382
|
/** Call every ~250 ms (setInterval locally, an alarm or the socket traffic on a Durable Object). */
|
|
1348
|
-
tick() {
|
|
1383
|
+
tick(idle = false) {
|
|
1349
1384
|
const now = this.now();
|
|
1350
1385
|
// Sockets that never said hello, or went silent (a slept phone, a half-open link): close them, and a seat
|
|
1351
1386
|
// becomes a bot instead of a frozen body.
|
|
1352
1387
|
for (const c of [...this.clients.values()]) {
|
|
1388
|
+
c.lastSeen=Math.max(c.lastSeen,c.conn.lastPing?.()??0);
|
|
1353
1389
|
if (!c.helloed) { if (now - c.joinedAt > 5000) { this.clients.delete(c.id); try { c.conn.close(4000, 'no-hello'); } catch { /* gone */ } } continue; }
|
|
1354
1390
|
// A loopback (the Table's own house agent) cannot be half-open: it is never closed for silence.
|
|
1355
1391
|
if (now - c.lastSeen > this.idleMs && !c.conn.loopback) this.kick(c, 'silent', 4000);
|
|
1356
1392
|
}
|
|
1357
1393
|
// A host that stopped sending snapshots while others are present.
|
|
1358
1394
|
const h = this.host();
|
|
1359
|
-
if (h && this.live().length > 1) {
|
|
1395
|
+
if (!idle && h && this.live().length > 1) {
|
|
1360
1396
|
const since = Math.max(h.lastSnapAt, this.hostSince, this.lastJoinAt);
|
|
1361
1397
|
if (now - since > this.stallMs) {
|
|
1362
1398
|
const next = this.elect(h.id, 'host-stalled');
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import {launchOf, settingsOf, accessOf, sameOrigin} from './office.mjs';
|
|
2
|
+
import {catalogue} from './catalogue.mjs';
|
|
3
|
+
/** Uses the same live session/account checks as the studio office. */
|
|
4
|
+
export async function isOwner(request, env) {
|
|
5
|
+
return (await import('./office.mjs')).isOwner(request, env);
|
|
6
|
+
}
|
|
7
|
+
/** Checks an app's declared role with its existing link, session and grant rules. */
|
|
8
|
+
export async function hasRole(request, env, app, role) {
|
|
9
|
+
const url = new URL(request.url);
|
|
10
|
+
const cat = await catalogue(env, url.origin);
|
|
11
|
+
const meta = cat.games?.find(item => item.id === app && item.kind === 'app');
|
|
12
|
+
if (!meta || typeof role !== 'string' || !Object.hasOwn(meta.roles ?? {}, role)) return false;
|
|
13
|
+
const launch = launchOf(meta, await settingsOf(env, {fresh:true}), env);
|
|
14
|
+
if (launch !== 'public' && !(await accessOf(request, env, app, launch)).ok) return false;
|
|
15
|
+
url.searchParams.set('role', role);
|
|
16
|
+
return (await (await import('./app-records.mjs')).appAccess(request, env, meta, url)).ok === true;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Return a refusal response, or null when the browser supplied same-origin evidence. */
|
|
20
|
+
export function requireSameOrigin(request) {
|
|
21
|
+
return sameOrigin(request, new URL(request.url)) ? null : new Response('Same origin required', {status:403});
|
|
22
|
+
}
|
package/worker/routes.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import {lobbyFor} from './lobby-shards.mjs';
|
|
1
2
|
/** Route dispatch; large interfaces and tool runtimes load at their own entry points. */
|
|
3
|
+
import {customRoute} from './custom-routes.mjs';
|
|
2
4
|
import { catalogue } from './catalogue.mjs';
|
|
3
5
|
|
|
4
6
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
@@ -40,6 +42,7 @@ export default async function route(request, env, ctx) {
|
|
|
40
42
|
let pubP = null;
|
|
41
43
|
// Every game (for a game's own pages and the owner's), and the public ones (every list, the rooms, the manifest).
|
|
42
44
|
const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
|
|
45
|
+
const customFallback = async () => (await customRoute(request, env, ctx)) ?? fallback(request, env, url, getCat);
|
|
43
46
|
const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
|
|
44
47
|
|
|
45
48
|
// Keep tool transports and discovery on the alias; redirect browser pages only.
|
|
@@ -53,7 +56,7 @@ export default async function route(request, env, ctx) {
|
|
|
53
56
|
|
|
54
57
|
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
55
58
|
|
|
56
|
-
if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
59
|
+
if (path === '/.well-known/jwks.json' || path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/hooks/studios/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
57
60
|
const { remoteMcp } = await import('./mcp.mjs');
|
|
58
61
|
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
59
62
|
}
|
|
@@ -281,7 +284,7 @@ export default async function route(request, env, ctx) {
|
|
|
281
284
|
if (game && GAME_ID.test(game)) {
|
|
282
285
|
const all = await getAll();
|
|
283
286
|
const meta = (all.games ?? []).find((g) => g.id === game);
|
|
284
|
-
if (!meta) return
|
|
287
|
+
if (!meta) return customFallback();
|
|
285
288
|
if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
|
|
286
289
|
let sub = parts.slice(1).join('/');
|
|
287
290
|
// Game bundles were already public assets; only the document carries access context.
|
|
@@ -338,7 +341,7 @@ export default async function route(request, env, ctx) {
|
|
|
338
341
|
if(!meta.room)return appRoleSession((await import('./records-page.mjs')).recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game,undefined,{request,meta,launch}):null),request,env,meta,launch);
|
|
339
342
|
}
|
|
340
343
|
}
|
|
341
|
-
const lobby = () => env.
|
|
344
|
+
const lobby = () => lobbyFor(env,game,/^[A-Za-z0-9_-]{16,43}$/.test(url.searchParams.get('b')??'')?url.searchParams.get('b'):'');
|
|
342
345
|
if (sub === 'invite') return redeemInvite(request, env, url, cat, meta, settings);
|
|
343
346
|
|
|
344
347
|
// SERVERS (worker/servers.mjs, NETPLAY.md section 17): which server a request is for — its path (/s/<id>/), its
|
|
@@ -629,7 +632,7 @@ export default async function route(request, env, ctx) {
|
|
|
629
632
|
const cur = versionOf(meta?.netplay?.version) ?? '';
|
|
630
633
|
const gv = versionOf(url.searchParams.get('gv')) ?? '';
|
|
631
634
|
const stall = Number(meta?.netplay?.stallMs) || 0;
|
|
632
|
-
const target = `https://table/${sub}?game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
635
|
+
const target = `https://table/${sub}?relay=1&game=${encodeURIComponent(game)}&room=${encodeURIComponent(room)}&max=${max}${b ? `&b=${b}` : ''}${who ? `&via=${encodeURIComponent(who)}` : ''}${sub === '__net' ? `&wp=${policy}${w ? '&w=1' : ''}` : ''}&pol=${encodeFacts(pol)}&chat=${encodeFacts(lean)}${acct ? '&acct=1' : ''}${member ? '&mem=1' : ''}${hub ? '&hub=1' : ''}${ag ? `&ag=${encodeFacts(ag)}` : ''}${badge ? `&bd=${encodeURIComponent(badge)}` : ''}&cur=${encodeURIComponent(cur)}${gv ? `&gv=${encodeURIComponent(gv)}` : ''}${stall ? `&stall=${stall}` : ''}${meta?.room?.contract ? `&rules=1&hz=${meta.room.tickHz ?? 20}` : ''}${meta?.room?.host === 'server' ? '&host=server' : ''}`;
|
|
633
636
|
// The room's own failure to answer is told apart from the browser going away while it was connecting (a closed
|
|
634
637
|
// tab, a reload racing its own socket): the second is a departure, said in one line with its room, never an
|
|
635
638
|
// uncaught error with nothing on it.
|
|
@@ -663,5 +666,5 @@ export default async function route(request, env, ctx) {
|
|
|
663
666
|
}
|
|
664
667
|
if (sub.startsWith('__game/')) return gameAsset(request, env, url, game, sub.slice('__game/'.length));
|
|
665
668
|
}
|
|
666
|
-
return
|
|
669
|
+
return customFallback();
|
|
667
670
|
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import {lobbyFor} from './lobby-shards.mjs';
|
|
1
2
|
import {studioTools,directoryOf} from './tool-registry.mjs';
|
|
2
3
|
export {useTools,getStudioTools} from './tool-registry.mjs';
|
|
3
4
|
import {json,finish} from './responses.mjs';
|
|
5
|
+
import {liveRoomSummaries} from './lobby-summary.mjs';
|
|
4
6
|
/** Shared catalogue, assets, and room presentation helpers. */
|
|
5
7
|
import {catalogue,builtPosts} from './catalogue.mjs';
|
|
6
8
|
|
|
@@ -138,11 +140,11 @@ async function roomsOf(env, games, { servers = null } = {}) {
|
|
|
138
140
|
const live = {};
|
|
139
141
|
// Room chat (section 19): whether each room's chat is on and may show on homie.rocks's page for it.
|
|
140
142
|
const chatRows = await chatRowsOf(env);
|
|
141
|
-
const summaries=
|
|
143
|
+
const summaries=await liveRoomSummaries(env);
|
|
142
144
|
await Promise.all(games.map(async (g) => {
|
|
143
145
|
let list = [];
|
|
144
146
|
if(summaries)list=summaries.get(g.id)??[];
|
|
145
|
-
else try { list = (await (await
|
|
147
|
+
else try { list = (await (await lobbyFor(env,g.id).fetch('https://lobby/rooms')).json()).rooms ?? []; } catch { list = []; }
|
|
146
148
|
const busy = list.filter((r) => (r.players || 0) > 0);
|
|
147
149
|
live[g.id] = busy.reduce((n, r) => n + (r.players || 0), 0);
|
|
148
150
|
const known = busy.some((r) => r.server && r.server !== 'public') ? (servers?.[g.id] ?? await serversOf(env, g).catch(() => [])) : [];
|
|
@@ -428,7 +430,7 @@ async function weekOf(env, game) {
|
|
|
428
430
|
const w = await playedThisWeek(env, game);
|
|
429
431
|
if (!w) return null;
|
|
430
432
|
let peak = 0;
|
|
431
|
-
try { peak = (await (await
|
|
433
|
+
try { peak = (await (await lobbyFor(env,game).fetch('https://lobby/now')).json()).peak?.players ?? 0; } catch { peak = 0; }
|
|
432
434
|
return { plays: w.plays, rounds: w.rounds, ...(peak ? { peak } : {}) };
|
|
433
435
|
}
|
|
434
436
|
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** Explicit containers keep user checkpoint keys from colliding with type tags. */
|
|
2
|
+
function pack(value) {
|
|
3
|
+
if(value===undefined)return ['u'];
|
|
4
|
+
if(typeof value==='number'&&!Number.isFinite(value))return ['n',String(value)];
|
|
5
|
+
if(value===null||typeof value!=='object')return ['v',value];
|
|
6
|
+
if(value instanceof Map)return ['m',[...value].map(([key,item])=>[pack(key),pack(item)])];
|
|
7
|
+
if(value instanceof Set)return ['s',[...value].map(pack)];
|
|
8
|
+
if(Array.isArray(value))return ['a',value.map(pack)];
|
|
9
|
+
return ['o',Object.entries(value).filter(([,item])=>typeof item!=='function').map(([key,item])=>[key,pack(item)])];
|
|
10
|
+
}
|
|
11
|
+
function unpack([type,value]) {
|
|
12
|
+
switch(type) {
|
|
13
|
+
case 'u':return undefined;
|
|
14
|
+
case 'n':return Number(value);
|
|
15
|
+
case 'v':return value;
|
|
16
|
+
case 'm':return new Map(value.map(([key,item])=>[unpack(key),unpack(item)]));
|
|
17
|
+
case 's':return new Set(value.map(unpack));
|
|
18
|
+
case 'a':return value.map(unpack);
|
|
19
|
+
case 'o':return Object.fromEntries(value.map(([key,item])=>[key,unpack(item)]));
|
|
20
|
+
default:throw new Error('Invalid room session value');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export const encodeSession=value=>JSON.stringify(pack(value));
|
|
24
|
+
export const decodeSession=value=>unpack(JSON.parse(value));
|
package/worker/stats.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {lobbyFor} from './lobby-shards.mjs';
|
|
1
2
|
import {writePageMetric,analyticsStatus} from './analytics.mjs';
|
|
2
3
|
/**
|
|
3
4
|
* STUDIO METRICS: count, don't track (@homie-rocks/studio 0.6.0).
|
|
@@ -255,7 +256,7 @@ async function playingNow(env, games) {
|
|
|
255
256
|
const out = {};
|
|
256
257
|
await Promise.all(games.map(async (g) => {
|
|
257
258
|
try {
|
|
258
|
-
const res = await
|
|
259
|
+
const res = await lobbyFor(env,g.id).fetch('https://lobby/now');
|
|
259
260
|
const body = await res.json();
|
|
260
261
|
out[g.id] = { players: Number(body.players) || 0, rooms: Number(body.rooms) || 0 };
|
|
261
262
|
} catch { out[g.id] = { players: 0, rooms: 0 }; }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {decode,originKey,verifyBytes} from './studios-keys.mjs';
|
|
2
|
+
import {oauthStorage,audit} from './mcp-store.mjs';
|
|
3
|
+
import {studioOrigin} from './studios-client.mjs';
|
|
4
|
+
const hash=async text=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(text))),b=>b.toString(16).padStart(2,'0')).join('');
|
|
5
|
+
const json=(data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store','pragma':'no-cache',...(status===401?{'www-authenticate':'Bearer'}:{})}});
|
|
6
|
+
export async function boundedBody(request,maxBytes=65536) {
|
|
7
|
+
const reader=request.body?.getReader();if(!reader)return '';
|
|
8
|
+
const chunks=[];let bytes=0;
|
|
9
|
+
try{while(true){const {done,value}=await reader.read();if(done)break;bytes+=value.byteLength;if(bytes>maxBytes)throw new Error('Request size exceeded');chunks.push(value);}}
|
|
10
|
+
catch(error){await reader.cancel();throw error;}finally{reader.releaseLock();}
|
|
11
|
+
const body=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){body.set(chunk,offset);offset+=chunk.length;}return new TextDecoder('utf-8',{fatal:true}).decode(body);
|
|
12
|
+
}
|
|
13
|
+
export function studioRegistration(cat,origin){
|
|
14
|
+
for(const rule of Object.values(cat.studio?.mcp?.studios?.inbound??{})){
|
|
15
|
+
try{if(studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true})===origin&&rule.enabled!==false)return rule;}catch{/* Invalid configuration is denied. */}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export async function setStudioPolicy(env,origin,allow){
|
|
20
|
+
origin=studioOrigin(origin,{allowPrivate:true});
|
|
21
|
+
await oauthStorage(env.DB).put('studio-policy:'+origin,JSON.stringify({allow:allow===true}));
|
|
22
|
+
}
|
|
23
|
+
export async function studioPolicy(env,cat,origin){
|
|
24
|
+
const rule=studioRegistration(cat,origin);
|
|
25
|
+
return rule&&(await oauthStorage(env.DB).get('studio-policy:'+origin,'json'))?.allow===true?rule:null;
|
|
26
|
+
}
|
|
27
|
+
/** RFC 7523 client authentication. Only the claimed origin's HTTPS JWKS can prove it. */
|
|
28
|
+
export async function studioToken(request,env,cat,options={}) {
|
|
29
|
+
if(request.method!=='POST')return null;
|
|
30
|
+
let form;try{form=new URLSearchParams(await boundedBody(request.clone(),8192));}catch{return json({error:'invalid_request'},400);}
|
|
31
|
+
if(form.get('grant_type')!=='client_credentials')return null;
|
|
32
|
+
const receiver=new URL(request.url).origin;
|
|
33
|
+
if(!receiver.startsWith('https:')||form.getAll('resource').length!==1||form.get('resource')!==receiver+'/mcp')return json({error:'invalid_target'},400);
|
|
34
|
+
if(form.has('scope')&&form.get('scope')!=='studio')return json({error:'invalid_scope'},400);
|
|
35
|
+
let origin,stage='assertion';
|
|
36
|
+
try{
|
|
37
|
+
for(const name of ['client_assertion','client_assertion_type','client_id'])if(form.getAll(name).length!==1)throw Error();
|
|
38
|
+
if(form.get('client_assertion_type')!=='urn:ietf:params:oauth:client-assertion-type:jwt-bearer')throw Error();
|
|
39
|
+
const parts=form.get('client_assertion').split('.');if(parts.length!==3)throw Error();
|
|
40
|
+
const header=decode(parts[0]),claims=decode(parts[1]);origin=claims.iss;
|
|
41
|
+
stage='policy';
|
|
42
|
+
const rule=await studioPolicy(env,cat,origin);if(!rule)throw Error();
|
|
43
|
+
stage='claims';
|
|
44
|
+
if(studioOrigin(origin,{allowPrivate:rule.allowPrivate===true})!==origin||form.get('client_id')!==origin||claims.sub!==origin||claims.aud!==receiver+'/oauth/token'||header.alg!=='EdDSA'||header.jku||header.jwk||header.crit)throw Error();
|
|
45
|
+
const now=Math.floor(Date.now()/1000);
|
|
46
|
+
if(!Number.isSafeInteger(claims.exp)||!Number.isSafeInteger(claims.iat)||claims.iat>now+30||claims.iat<now-300||claims.exp<=now||claims.exp>claims.iat+300||claims.nbf!==undefined&&(!Number.isSafeInteger(claims.nbf)||claims.nbf>now)||typeof claims.jti!=='string'||! /^[A-Za-z0-9_-]{1,128}$/.test(claims.jti))throw Error();
|
|
47
|
+
stage='key discovery';
|
|
48
|
+
const key=await originKey(env,origin,header.kid,{...options,allowPrivate:rule.allowPrivate===true});
|
|
49
|
+
stage='signature';
|
|
50
|
+
if(!await verifyBytes(key,parts[2],parts.slice(0,2).join('.')))throw Error();
|
|
51
|
+
stage='replay';
|
|
52
|
+
const used=await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) RETURNING key').bind('studio-jti:'+await hash(JSON.stringify([origin,claims.jti])),'1',claims.exp).first();if(!used)throw Error();
|
|
53
|
+
}catch{
|
|
54
|
+
await audit(env,{client:typeof origin==='string'?origin.slice(0,2048):null},'studio-auth','denied:'+stage);
|
|
55
|
+
return json({error:'invalid_client',error_description:'Studio authentication failed'},401);
|
|
56
|
+
}
|
|
57
|
+
const token='homie_studio_'+Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,'0')).join('');
|
|
58
|
+
const record={origin,resource:receiver+'/mcp',expires:Math.floor(Date.now()/1000)+300};
|
|
59
|
+
await oauthStorage(env.DB).put('studio-token:'+await hash(token),JSON.stringify(record),{expiration:record.expires});
|
|
60
|
+
return json({access_token:token,token_type:'Bearer',expires_in:300,scope:'studio'});
|
|
61
|
+
}
|
|
62
|
+
/** Live D1 policy is re-read on every dispatch; handles only separate booking ownership. */
|
|
63
|
+
export async function studioCaller(request,env,cat) {
|
|
64
|
+
const token=/^Bearer (homie_studio_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
|
|
65
|
+
const record=await oauthStorage(env.DB).get('studio-token:'+await hash(token),'json');
|
|
66
|
+
if(!record||record.expires<=Date.now()/1000||record.resource!==new URL(request.url).origin+'/mcp')return null;
|
|
67
|
+
const rule=await studioPolicy(env,cat,record.origin);if(!rule)return null;
|
|
68
|
+
let assertedHandle;try{const raw=request.headers.get('x-studio-asserted-handle');if(raw!==null){assertedHandle=decodeURIComponent(raw);if(!/^[A-Za-z0-9._@-]{1,128}$/.test(assertedHandle))return null;}}catch{return null;}
|
|
69
|
+
return {id:'studio:'+await hash(JSON.stringify([record.origin,assertedHandle??null])),owner:false,roles:{},client:record.origin,tools:rule.tools??[],
|
|
70
|
+
studio:{origin:record.origin,verified:true,method:'private_key_jwt'},...(assertedHandle===undefined?{}:{actingFor:{assertedHandle,verified:false,assertedBy:record.origin}})};
|
|
71
|
+
}
|