@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,93 @@
|
|
|
1
|
+
import {signAssertion} from './studios-keys.mjs';
|
|
2
|
+
import {Client, StreamableHTTPClientTransport, PrivateKeyJwtProvider} from 'mcp-studio-client';
|
|
3
|
+
export function studioOrigin(value,{allowPrivate=false}={}) {
|
|
4
|
+
const url=new URL(value);
|
|
5
|
+
if(url.protocol!=='https:'||url.username||url.password||url.pathname!=='/'||url.search||url.hash)throw new Error('Studio requires a bare HTTPS origin');
|
|
6
|
+
const host=url.hostname.toLowerCase().replace(/\.$/,'');
|
|
7
|
+
if(!allowPrivate&&(url.port||host.includes(':')||/^[0-9.]+$/.test(host)||!host.includes('.')||/(^|\.)(localhost|local|internal)$/.test(host)))throw new Error('Studio requires a public DNS origin on port 443');
|
|
8
|
+
return url.origin;
|
|
9
|
+
}
|
|
10
|
+
/** Bound bytes before SDK parsing; the SDK owns JSON-RPC, SSE and OAuth. */
|
|
11
|
+
export function boundedStudioFetch(origin,signal,{maxBytes=1024*1024,fetchFn=globalThis.fetch.bind(globalThis)}={}) {
|
|
12
|
+
return async(input,init)=>{
|
|
13
|
+
const request=new Request(input,init),url=new URL(request.url);
|
|
14
|
+
if(url.origin!==origin||url.username||url.password)throw new Error('Studio request escaped its origin');
|
|
15
|
+
const response=await fetchFn(new Request(request,{redirect:'manual',signal:AbortSignal.any([signal,request.signal])}));
|
|
16
|
+
if(response.status>=300&&response.status<400){await response.body?.cancel();throw new Error('Studio redirects refused');}
|
|
17
|
+
if(Number(response.headers.get('content-length'))>maxBytes){await response.body?.cancel();throw new Error('Studio response size exceeded');}
|
|
18
|
+
if(!response.body)return response;
|
|
19
|
+
let bytes=0;
|
|
20
|
+
const body=response.body.pipeThrough(new TransformStream({transform(chunk,controller){bytes+=chunk.byteLength;if(bytes>maxBytes)throw new Error('Studio response size exceeded');controller.enqueue(chunk);}}));
|
|
21
|
+
return new Response(body,{status:response.status,statusText:response.statusText,headers:response.headers});
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function studioResult(result,source={origin:null,authenticated:false}) {
|
|
25
|
+
let data=result.structuredContent;
|
|
26
|
+
if(data===undefined&&result.content?.length===1&&result.content[0].type==='text'){
|
|
27
|
+
try{const parsed=JSON.parse(result.content[0].text);if(parsed?.kind==='untrusted-tool-data')data=parsed;}catch{/* Ordinary MCP text remains content. */}
|
|
28
|
+
}
|
|
29
|
+
if(data?.kind==='untrusted-tool-data'&&Object.hasOwn(data,'data'))data=data.data;
|
|
30
|
+
return {source,untrusted:true,isError:result.isError===true,...(data===undefined?{}:{data}),content:result.content??[]};
|
|
31
|
+
}
|
|
32
|
+
export function outboundRules(cat) {
|
|
33
|
+
const rules=new Map();
|
|
34
|
+
for(const [key,rule] of Object.entries(cat.studio?.mcp?.studios?.outbound??{})) {
|
|
35
|
+
const origin=studioOrigin(key,{allowPrivate:rule.allowPrivate===true});
|
|
36
|
+
if(rules.has(origin)||rule.origin!==undefined&&studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true})!==origin)throw new Error('Studio configuration origin mismatch');
|
|
37
|
+
rules.set(origin,rule);
|
|
38
|
+
}
|
|
39
|
+
return rules;
|
|
40
|
+
}
|
|
41
|
+
export function studiosClient(env,cat,definition,{fetchFn=globalThis.fetch.bind(globalThis),origin:ownOrigin}={}) {
|
|
42
|
+
const providers=new Map(),discovery=new Map();
|
|
43
|
+
const rules=outboundRules(cat);
|
|
44
|
+
const run=async(origin,operation,options={})=>{
|
|
45
|
+
const candidate=new URL(origin).origin,rule=rules.get(candidate);
|
|
46
|
+
origin=studioOrigin(origin,{allowPrivate:rule?.allowPrivate===true});
|
|
47
|
+
if(!rule)throw new Error('Studio destination is not configured');
|
|
48
|
+
if(rule&&!rule.tools?.includes(definition.name))throw new Error('Studio connection not delegated to this tool');
|
|
49
|
+
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(new Error('Studio request timed out')),10000);
|
|
50
|
+
const client=new Client({name:'homie-studio',version:'1'});
|
|
51
|
+
try{
|
|
52
|
+
const authenticated=rule.anonymous!==true;
|
|
53
|
+
let authProvider;
|
|
54
|
+
if(authenticated){
|
|
55
|
+
if(!ownOrigin||!env.STUDIO_SIGNING_KEY)throw new Error('Studio signing key unavailable');
|
|
56
|
+
authProvider=providers.get(origin);
|
|
57
|
+
if(!authProvider){
|
|
58
|
+
authProvider=new PrivateKeyJwtProvider({clientId:ownOrigin,privateKey:{},algorithm:'EdDSA',scope:'studio',expectedIssuer:origin});
|
|
59
|
+
authProvider.addClientAuthentication=async(_headers,params,url)=>{
|
|
60
|
+
if(String(url)!==origin+'/oauth/token')throw new Error('Unexpected studio token endpoint');
|
|
61
|
+
params.set('client_id',ownOrigin);
|
|
62
|
+
params.set('client_assertion_type','urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
63
|
+
params.set('client_assertion',await signAssertion(env,ownOrigin,String(url)));
|
|
64
|
+
};
|
|
65
|
+
providers.set(origin,authProvider);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const cachedFetch=async(request)=>{
|
|
69
|
+
const url=new URL(request.url),cacheable=request.method==='GET'&&url.pathname.startsWith('/.well-known/');
|
|
70
|
+
if(cacheable&&discovery.has(request.url))return discovery.get(request.url).clone();
|
|
71
|
+
const response=await fetchFn(request);
|
|
72
|
+
if(cacheable&&response.ok){const bounded=await (await boundedStudioFetch(origin,controller.signal,{fetchFn:async()=>response,maxBytes:32768})(request)).arrayBuffer();if(bounded.byteLength>32768)throw new Error('Discovery size exceeded');const saved=new Response(bounded,{status:response.status,headers:response.headers});discovery.set(request.url,saved);return saved.clone();}
|
|
73
|
+
return response;
|
|
74
|
+
};
|
|
75
|
+
const headers={};
|
|
76
|
+
if(options.assertedHandle!==undefined){
|
|
77
|
+
if(!authenticated||typeof options.assertedHandle!=='string'||! /^[A-Za-z0-9._@-]{1,128}$/.test(options.assertedHandle))throw new Error('Acting-for needs an authenticated studio and a handle of 1–128 characters');
|
|
78
|
+
headers['x-studio-asserted-handle']=encodeURIComponent(options.assertedHandle);
|
|
79
|
+
}
|
|
80
|
+
const transport=new StreamableHTTPClientTransport(new URL(authenticated?'/mcp/auth':'/mcp',origin),{fetch:boundedStudioFetch(origin,controller.signal,{fetchFn:async request=>cachedFetch(request)}),authProvider,requestInit:{headers}});
|
|
81
|
+
await client.connect(transport,{signal:controller.signal,timeout:10000});
|
|
82
|
+
return await operation(client,controller.signal,{origin,authenticated});
|
|
83
|
+
}finally{controller.abort();clearTimeout(timer);await client.close();}
|
|
84
|
+
};
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
send:(origin,name,event)=>import('./studios-events.mjs').then(m=>m.sendStudioEvent(env,cat,ownOrigin,definition,origin,name,event,{fetchFn})),
|
|
87
|
+
listTools:(origin,options)=>run(origin,async(client,signal,source)=>{const page=await client.listTools(options?.cursor?{cursor:options.cursor}:{},{signal,timeout:10000});return {...page,source,untrusted:true,tools:page.tools.map(tool=>({...tool,source,untrusted:true,description:'[Untrusted remote description] '+(tool.description??'')}))};},options),
|
|
88
|
+
call:(origin,tool,args={},options)=>{
|
|
89
|
+
if(new TextEncoder().encode(JSON.stringify(args)).length>65536)return Promise.reject(new Error('Studio arguments size exceeded'));
|
|
90
|
+
return run(origin,async(client,signal,source)=>studioResult(await client.callTool({name:tool,arguments:args},{signal,timeout:10000}),source),options);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {boundedBody,studioPolicy} from './studios-auth.mjs';
|
|
2
|
+
import {studioOrigin,outboundRules,boundedStudioFetch} from './studios-client.mjs';
|
|
3
|
+
import {signBytes,originKey,verifyBytes} from './studios-keys.mjs';
|
|
4
|
+
import {publishBusinessEvent,BUSINESS_EVENT_MAX_BYTES} from './business-events.mjs';
|
|
5
|
+
import {syncFunctions} from './functions.mjs';
|
|
6
|
+
export async function signStudioEvent(env,source,name,{id,data},{now=Date.now}={}){
|
|
7
|
+
if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,128}$/.test(id))throw new Error('Stable event id required');
|
|
8
|
+
const body=JSON.stringify({eventId:id,name,timestamp:new Date(now()).toISOString(),source,data});
|
|
9
|
+
if(new TextEncoder().encode(body).length>BUSINESS_EVENT_MAX_BYTES)throw new Error('Studio event exceeds 8192 bytes');
|
|
10
|
+
const timestamp=String(Math.floor(now()/1000)),{kid,signature}=await signBytes(env,`${id}.${timestamp}.${body}`);
|
|
11
|
+
return {body,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':'v1a,'+signature,'webhook-key-id':kid,'webhook-origin':source}};
|
|
12
|
+
}
|
|
13
|
+
export async function sendStudioEvent(env,cat,source,definition,origin,name,event,options={}) {
|
|
14
|
+
const rule=outboundRules(cat).get(new URL(origin).origin);origin=studioOrigin(origin,{allowPrivate:rule?.allowPrivate===true});
|
|
15
|
+
if(!rule?.tools?.includes(definition.name)||!rule.events?.includes(name)||!/^\/hooks\/studios\/[a-zA-Z0-9_-]+$/.test(rule.eventPath??''))throw new Error('Studio event destination not delegated');
|
|
16
|
+
if(rule.anonymous||!source)throw new Error('Studio event requires origin authentication');
|
|
17
|
+
const signed=await signStudioEvent(env,source,name,event,options);
|
|
18
|
+
const response=await boundedStudioFetch(origin,AbortSignal.timeout(10000),options)(origin+rule.eventPath,{method:'POST',...signed});
|
|
19
|
+
await response.body?.cancel();if(!response.ok)throw new Error('Studio event rejected ('+response.status+')');
|
|
20
|
+
return {id:event.id};
|
|
21
|
+
}
|
|
22
|
+
/** Standard Webhooks v1a: Ed25519 over id.timestamp.exact-body, keys fetched only from configured origin. */
|
|
23
|
+
export async function receiveStudioEvent(request,env,cat,options={}) {
|
|
24
|
+
if(request.method!=='POST')return new Response(null,{status:405});
|
|
25
|
+
const id=new URL(request.url).pathname.slice('/hooks/studios/'.length),entries=cat.studio?.mcp?.studios?.inbound??{};
|
|
26
|
+
const configured=Object.hasOwn(entries,id)?entries[id]:null;
|
|
27
|
+
let origin,rule;try{origin=studioOrigin(configured?.origin,{allowPrivate:configured?.allowPrivate===true});if(configured.enabled!==false&&await studioPolicy(env,cat,origin))rule=configured;}catch{}
|
|
28
|
+
if(!rule)return new Response(null,{status:403});
|
|
29
|
+
let body;try{body=await boundedBody(request,BUSINESS_EVENT_MAX_BYTES);}catch{return new Response('Studio event exceeds 8192 bytes',{status:413});}
|
|
30
|
+
let event;
|
|
31
|
+
try{
|
|
32
|
+
const timestamp=request.headers.get('webhook-timestamp'),eventId=request.headers.get('webhook-id');
|
|
33
|
+
if(!/^\d+$/.test(timestamp??'')||Math.abs(Date.now()/1000-Number(timestamp))>300||!/^[A-Za-z0-9_-]{1,128}$/.test(eventId??'')||request.headers.get('webhook-origin')!==origin)throw Error();
|
|
34
|
+
const key=await originKey(env,origin,request.headers.get('webhook-key-id'),{...options,allowPrivate:rule.allowPrivate===true});
|
|
35
|
+
let valid=false;for(const part of (request.headers.get('webhook-signature')??'').split(' ').slice(0,8)){
|
|
36
|
+
const [version,signature]=part.split(',');if(version==='v1a'&&await verifyBytes(key,signature,`${eventId}.${timestamp}.${body}`)){valid=true;break;}
|
|
37
|
+
}
|
|
38
|
+
if(!valid)throw Error();event=JSON.parse(body);
|
|
39
|
+
}catch{return new Response(null,{status:401});}
|
|
40
|
+
if(!event||typeof event!=='object'||Array.isArray(event)||event.source!==origin||event.eventId!==request.headers.get('webhook-id')||typeof event.name!=='string')return new Response(null,{status:400});
|
|
41
|
+
const feed=Object.hasOwn(rule.events??{},event.name)?rule.events[event.name]:null;
|
|
42
|
+
if(!feed)return new Response(null,{status:403});
|
|
43
|
+
await syncFunctions(env);
|
|
44
|
+
try{await publishBusinessEvent(env,{}, {publish:[feed]},'@studio:'+origin,{feed,id:event.eventId,data:{sender:{origin,verified:true},data:event.data}});}
|
|
45
|
+
catch(error){
|
|
46
|
+
if(/already published/.test(error.message))return new Response(null,{status:409});
|
|
47
|
+
if(/8192 bytes/.test(error.message))return new Response(error.message,{status:413});
|
|
48
|
+
if(/schema|characters/.test(error.message))return new Response(null,{status:400});
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return new Response(null,{status:204});
|
|
52
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {oauthStorage} from './mcp-store.mjs';
|
|
2
|
+
import {studioOrigin,boundedStudioFetch} from './studios-client.mjs';
|
|
3
|
+
const bytes=text=>new TextEncoder().encode(text);
|
|
4
|
+
export const base64=value=>btoa(String.fromCharCode(...new Uint8Array(value)));
|
|
5
|
+
export const unbase64=value=>Uint8Array.from(atob(value),c=>c.charCodeAt(0));
|
|
6
|
+
const encode=value=>base64(bytes(JSON.stringify(value))).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
|
|
7
|
+
export const decode=value=>JSON.parse(new TextDecoder().decode(unbase64(value.replaceAll('-','+').replaceAll('_','/'))));
|
|
8
|
+
export function publicKeys(env){
|
|
9
|
+
const current=JSON.parse(env.STUDIO_SIGNING_KEY??'null');
|
|
10
|
+
const keys=[current,...JSON.parse(env.STUDIO_PREVIOUS_PUBLIC_KEYS??'[]')].filter(Boolean);
|
|
11
|
+
return {keys:keys.map(({kty,crv,x,kid})=>({kty,crv,x,kid,alg:'EdDSA',use:'sig'}))};
|
|
12
|
+
}
|
|
13
|
+
export async function signBytes(env,text){
|
|
14
|
+
const jwk=JSON.parse(env.STUDIO_SIGNING_KEY??'null');
|
|
15
|
+
if(!jwk?.kid||jwk.crv!=='Ed25519'||!jwk.d)throw new Error('Studio signing key unavailable');
|
|
16
|
+
const key=await crypto.subtle.importKey('jwk',{...jwk,alg:'EdDSA'},'Ed25519',false,['sign']);
|
|
17
|
+
return {kid:jwk.kid,signature:base64(await crypto.subtle.sign('Ed25519',key,bytes(text)))};
|
|
18
|
+
}
|
|
19
|
+
export async function signAssertion(env,origin,audience){
|
|
20
|
+
const jwk=JSON.parse(env.STUDIO_SIGNING_KEY??'null'),now=Math.floor(Date.now()/1000);
|
|
21
|
+
const text=encode({alg:'EdDSA',typ:'JWT',kid:jwk?.kid})+'.'+encode({iss:origin,sub:origin,aud:audience,iat:now,exp:now+60,jti:crypto.randomUUID()});
|
|
22
|
+
return text+'.'+(await signBytes(env,text)).signature.replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
|
|
23
|
+
}
|
|
24
|
+
/** D1 cache and atomic per-origin fetch claim bound failures and unknown kids across requests. */
|
|
25
|
+
export async function originKey(env,origin,kid,{fetchFn=globalThis.fetch.bind(globalThis),allowPrivate=false}={}){
|
|
26
|
+
origin=studioOrigin(origin,{allowPrivate});
|
|
27
|
+
if(typeof kid!=='string'||! /^[A-Za-z0-9_-]{1,128}$/.test(kid))throw new Error('Invalid key id');
|
|
28
|
+
const store=oauthStorage(env.DB),cacheKey='studio-jwks:'+origin,now=Date.now();
|
|
29
|
+
let cached=await store.get(cacheKey,'json');
|
|
30
|
+
if(!cached||cached.until<=now||!cached.keys.some(k=>k.kid===kid)&&now-cached.at>=60000){
|
|
31
|
+
// Claim before network I/O: concurrent misses cannot each fetch. Milliseconds
|
|
32
|
+
// in value enforce a full 60 seconds; expires is the storage cleanup bound.
|
|
33
|
+
const claim=await env.DB.prepare("INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) ON CONFLICT(key) DO UPDATE SET value=excluded.value,expires=excluded.expires WHERE CAST(mcp_kv.value AS INTEGER)<=?4 RETURNING key")
|
|
34
|
+
.bind('studio-jwks-fetch:'+origin,String(now+60000),Math.ceil((now+60000)/1000),now).first();
|
|
35
|
+
if(claim){
|
|
36
|
+
try{
|
|
37
|
+
const fetch=boundedStudioFetch(origin,AbortSignal.timeout(5000),{fetchFn,maxBytes:32768});
|
|
38
|
+
const response=await fetch(origin+'/.well-known/jwks.json');if(!response.ok)throw new Error('Studio keys unavailable');
|
|
39
|
+
const jwks=await response.json();
|
|
40
|
+
if(!Array.isArray(jwks?.keys)||jwks.keys.length>8||jwks.keys.some(k=>!k||typeof k!=='object'||k.d))throw new Error('Invalid studio keys');
|
|
41
|
+
cached={keys:jwks.keys,at:now,until:now+300000};
|
|
42
|
+
}catch{
|
|
43
|
+
// Preserve still-valid known keys when an unknown-kid refresh fails.
|
|
44
|
+
cached={keys:cached?.until>now?cached.keys:[],at:now,until:Math.max(cached?.until??0,now+60000)};
|
|
45
|
+
}
|
|
46
|
+
await store.put(cacheKey,JSON.stringify(cached),{expiration:Math.ceil(cached.until/1000)});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if(!cached||cached.until<=now)throw new Error('Studio keys unavailable');
|
|
50
|
+
const matches=cached.keys.filter(k=>k.kid===kid&&k.kty==='OKP'&&k.crv==='Ed25519'&&(!k.alg||k.alg==='EdDSA')&&(!k.use||k.use==='sig'));
|
|
51
|
+
if(matches.length!==1)throw new Error('Unknown studio key');
|
|
52
|
+
return crypto.subtle.importKey('jwk',matches[0],'Ed25519',false,['verify']);
|
|
53
|
+
}
|
|
54
|
+
export async function verifyBytes(key,signature,text){return crypto.subtle.verify('Ed25519',key,unbase64(signature.replaceAll('-','+').replaceAll('_','/')),bytes(text));}
|