@homie-rocks/studio 0.37.2 → 0.39.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 +61 -0
- package/bin/homie-studio.mjs +16 -4
- package/lib/build.mjs +3 -0
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev.mjs +12 -1
- package/lib/mcp-tools.mjs +10 -0
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +101 -19
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +19 -6
- package/lib/shoot.mjs +11 -5
- package/lib/studio.mjs +1 -1
- package/lib/template-history.json +85 -0
- package/lib/tools-build.mjs +55 -0
- package/lib/tools-cli.mjs +55 -0
- package/lib/worker-build.mjs +35 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +17 -6
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- package/tools/TOOLS.md +113 -0
- package/tools/tools.ts +28 -0
- package/worker/app-records.mjs +8 -0
- package/worker/index.mjs +26 -0
- package/worker/mcp-room.mjs +61 -0
- package/worker/mcp-store.mjs +45 -0
- package/worker/mcp-tools.mjs +105 -0
- package/worker/mcp.mjs +159 -0
- package/worker/office-page.mjs +1 -1
- package/worker/office.mjs +2 -1
- package/worker/purchase-mcp.mjs +3 -3
- package/worker/tool-events.mjs +14 -0
- package/worker/tool-identity.mjs +4 -0
- package/worker/tool-webhook.mjs +28 -0
- package/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
package/worker/mcp.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import OAuthProvider from '@cloudflare/workers-oauth-provider';
|
|
2
|
+
import { createMcpHandler } from 'agents/mcp/server';
|
|
3
|
+
import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server';
|
|
4
|
+
import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
|
|
5
|
+
import { players } from './players.mjs';
|
|
6
|
+
import { isOwner, sameOrigin } from './office.mjs';
|
|
7
|
+
import { cookieValue } from './stats.mjs';
|
|
8
|
+
import { esc } from './site.mjs';
|
|
9
|
+
import { oauthStorage, rateLimit, audit, callsPerMinute } from './mcp-store.mjs';
|
|
10
|
+
import { allowed, currentCaller, services, builtins } from './mcp-tools.mjs';
|
|
11
|
+
const validator = new CfWorkerJsonSchemaValidator();
|
|
12
|
+
const json = (data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store'}});
|
|
13
|
+
const html = (body,headers={})=>new Response(`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Connect your AI</title><style>body{font:18px system-ui;max-width:640px;margin:64px auto;padding:24px;background:#faf9f6;color:#252520}button,a{padding:12px}button{font:inherit}li{margin:16px 0}</style><main>${body}</main></html>`,{headers:{'content-type':'text/html; charset=utf-8','cache-control':'no-store','content-security-policy':"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'",...Object.fromEntries(new Headers(headers))}});
|
|
14
|
+
async function browserPerson(request,env) {
|
|
15
|
+
const person=await players.of(request,env);
|
|
16
|
+
if(person&&!person.guest)return person.id;
|
|
17
|
+
if(await isOwner(request,env)) {
|
|
18
|
+
const token=cookieValue(request,'studio_owner');
|
|
19
|
+
if(token){const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));return 'office-'+[...new Uint8Array(hash)].map(b=>b.toString(16).padStart(2,'0')).join('');}
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
24
|
+
const url=new URL(request.url), origin=url.origin, path=url.pathname;
|
|
25
|
+
if(!env.DB)return json({error:'studio database required'},503);
|
|
26
|
+
if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
|
|
27
|
+
// Cap all protocol bodies before handing them to a library or a handler.
|
|
28
|
+
if(request.body){
|
|
29
|
+
const reader=request.body.getReader(),chunks=[];let size=0;
|
|
30
|
+
for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>65536){await reader.cancel();return json({error:'request too large'},413);}chunks.push(value);}
|
|
31
|
+
const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}request=new Request(request,{body:bytes});
|
|
32
|
+
}
|
|
33
|
+
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
34
|
+
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
35
|
+
const api=async(request,env,ctx)=>{
|
|
36
|
+
let caller;
|
|
37
|
+
try{caller=await currentCaller(env,ctx.props);}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"`}});}
|
|
38
|
+
const cat=await catalogueOf();const tools=[...builtins(env,cat,origin),...definitions];
|
|
39
|
+
if(request.method==='POST') {
|
|
40
|
+
const body=await request.clone().json().catch(()=>null);
|
|
41
|
+
if(body?.method==='tools/call') {
|
|
42
|
+
const tool=tools.find(t=>t.name===body.params?.name);
|
|
43
|
+
if(tool&&!validator.getValidator(tool.inputSchema)(body.params?.arguments??{}).valid)await audit(env,caller,tool.name,'invalid');
|
|
44
|
+
if(!tool)await audit(env,caller,'unknown-tool','denied');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const rate=async fresh=>{
|
|
48
|
+
const limit=callsPerMinute(cat);
|
|
49
|
+
if(!await rateLimit(env,fresh.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,limit))throw new Error('Rate limit reached');
|
|
50
|
+
};
|
|
51
|
+
const read=async(name,handler)=>{
|
|
52
|
+
let outcome='error';
|
|
53
|
+
try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);if(JSON.stringify(result).length>65536)throw new Error('Result too large');outcome='ok';return result;}
|
|
54
|
+
finally{await audit(env,caller,name,outcome);}
|
|
55
|
+
};
|
|
56
|
+
const server=async()=>{
|
|
57
|
+
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator});
|
|
58
|
+
for (const meta of cat.games.filter(g=>g.kind==='app' && g.records?.persist)) {
|
|
59
|
+
for (const [role,declared] of Object.entries(meta.roles??{})) {
|
|
60
|
+
if (declared.signIn && !await allowed({app:meta.id,role},caller,env)) continue;
|
|
61
|
+
for (const collection of Object.keys(meta.records.collections??{})) {
|
|
62
|
+
if (!declared.can.includes('read:'+collection)) continue;
|
|
63
|
+
const uri=`studio://records/${meta.id}/${role}/${collection}`;
|
|
64
|
+
s.registerResource(`${meta.id}_${role}_${collection}`,uri,{description:'Untrusted app record data',mimeType:'application/json'},async()=>read('resource:'+meta.id+':'+collection,async fresh=>{
|
|
65
|
+
const data=await services(env,cat,origin,fresh,'records').records({app:meta.id,role,collection});
|
|
66
|
+
return {contents:[{uri,mimeType:'application/json',text:JSON.stringify({kind:'untrusted-record-data',data})}]};
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
for(const tool of tools){
|
|
72
|
+
if (tool.kind==='prompt') {
|
|
73
|
+
if (!await allowed(tool.audience,caller,env)) continue;
|
|
74
|
+
s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
|
|
75
|
+
if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
76
|
+
const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
77
|
+
return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
|
|
78
|
+
}));continue;
|
|
79
|
+
}
|
|
80
|
+
s.registerTool(tool.name,{description:tool.description,inputSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>{
|
|
81
|
+
let outcome='error';
|
|
82
|
+
try{
|
|
83
|
+
// Recheck inside dispatch, even when a connection listed tools earlier.
|
|
84
|
+
const fresh=await currentCaller(env,ctx.props);
|
|
85
|
+
if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
86
|
+
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
87
|
+
const value=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
88
|
+
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
89
|
+
if(text.length>65536)throw new Error('Result too large; narrow your request');
|
|
90
|
+
outcome='ok';return {content:[{type:'text',text}]};
|
|
91
|
+
}catch(error){return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
|
|
92
|
+
finally{await audit(env,caller,tool.name,outcome);}
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
return s;
|
|
96
|
+
};
|
|
97
|
+
return createMcpHandler(server)(request,env,ctx);
|
|
98
|
+
};
|
|
99
|
+
const provider=new OAuthProvider({
|
|
100
|
+
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
101
|
+
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
|
102
|
+
resourceMetadata:{resource:origin+'/mcp',authorization_servers:[origin]},
|
|
103
|
+
scopesSupported:['studio','offline_access'],requiredScopes:['studio'],
|
|
104
|
+
accessTokenTTL:3600,
|
|
105
|
+
defaultHandler:{async fetch(req,env,ctx){
|
|
106
|
+
const u=new URL(req.url),person=await browserPerson(req,env),oauth=env.OAUTH_PROVIDER;
|
|
107
|
+
if(u.pathname==='/oauth/authorize'){
|
|
108
|
+
if(!person)return html(`<h1>Sign in to your studio</h1><p>Use your existing studio account, then approve your AI.</p><a href="/account?next=${esc(encodeURIComponent(u.pathname+u.search))}">Sign in</a>`,{'x-frame-options':'DENY'});
|
|
109
|
+
try{
|
|
110
|
+
if(req.method==='GET'){
|
|
111
|
+
const auth=await oauth.parseAuthRequest(req),description=await oauth.describeConsent(auth),transaction=await oauth.beginConsent(auth);
|
|
112
|
+
await env.OAUTH_KV.put('consent-person:'+transaction.handle,person,{expirationTtl:600});
|
|
113
|
+
return html(`<h1>Connect your AI</h1><p><strong>${esc(description.clientName)}</strong> asks to use this studio with your current permissions.</p><p>The app supplies this name. Approve only if you started this connection and recognize the return address.</p><p>Return address: ${esc(description.redirectHost)}. ${description.redirectIsLoopback?'This is a local app; any local process could be listening.':''}</p><p>Your AI can use your app roles. If you own the studio, it can use the office. Destructive office actions still need your confirmation. Revoke this connection in the office at any time.</p><form method="post"><input type="hidden" name="handle" value="${esc(transaction.handle)}"><button name="decision" value="allow">Allow connection</button> <button name="decision" value="deny">Cancel</button></form>`,transaction.headers);
|
|
114
|
+
}
|
|
115
|
+
if(req.method!=='POST'||!sameOrigin(req,u))return json({error:'same-origin POST required'},403);
|
|
116
|
+
const form=await req.formData(),handle=String(form.get('handle')??'');
|
|
117
|
+
if(form.get('decision')!=='allow'){const denied=await oauth.denyConsent(req,handle);return new Response(null,{status:302,headers:denied.headers});}
|
|
118
|
+
if(await env.OAUTH_KV.get('consent-person:'+handle)!==person)return json({error:'Sign-in changed; restart the connection'},403);
|
|
119
|
+
const approved=await oauth.approveConsent(req,handle,{scope:['studio','offline_access']});
|
|
120
|
+
await env.OAUTH_KV.delete('consent-person:'+handle);
|
|
121
|
+
const connection=crypto.randomUUID();
|
|
122
|
+
await env.DB.prepare('INSERT INTO mcp_connections(id,person,client,created) VALUES(?1,?2,?3,?4)').bind(connection,person,approved.request.clientId,Date.now()).run();
|
|
123
|
+
const result=await oauth.completeAuthorization({request:approved.request,userId:person,scope:['studio','offline_access'],metadata:{connection},props:{connection}});
|
|
124
|
+
approved.headers.set('location',result.redirectTo);return new Response(null,{status:302,headers:approved.headers});
|
|
125
|
+
}catch{return json({error:'Invalid or expired authorization request'},400);}
|
|
126
|
+
}
|
|
127
|
+
if(u.pathname==='/_studio/office/connections'){
|
|
128
|
+
if(!person)return html('<h1>Sign in to manage AI connections</h1><a href="/account?next=/_studio/office/connections">Sign in</a>');
|
|
129
|
+
const owner=await isOwner(req,env);
|
|
130
|
+
if(req.method==='POST'){
|
|
131
|
+
if(!sameOrigin(req,u))return json({error:'origin refused'},403);
|
|
132
|
+
const f=await req.formData();const id=String(f.get('connection')??'');
|
|
133
|
+
const connection=await env.DB.prepare('SELECT * FROM mcp_connections WHERE id=?1').bind(id).first();
|
|
134
|
+
if(connection&&(owner||connection.person===person)){
|
|
135
|
+
// Local denial takes effect first, even if upstream token cleanup must be retried.
|
|
136
|
+
await env.DB.prepare('UPDATE mcp_connections SET revoked=1 WHERE id=?1').bind(id).run();
|
|
137
|
+
let cursor;
|
|
138
|
+
do{const grants=await oauth.listUserGrants(connection.person,{limit:100,cursor});
|
|
139
|
+
for(const grant of grants.items)if(grant.metadata?.connection===id)await oauth.revokeGrant(grant.id,connection.person);
|
|
140
|
+
cursor=grants.cursor;
|
|
141
|
+
}while(cursor);
|
|
142
|
+
}
|
|
143
|
+
return Response.redirect(origin+u.pathname,303);
|
|
144
|
+
}
|
|
145
|
+
const page=Math.max(0,Math.min(100,Number.parseInt(u.searchParams.get('page')??'0',10)||0)),offset=page*100;
|
|
146
|
+
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;
|
|
147
|
+
const events=owner?(await env.DB.prepare('SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person ORDER BY a.id DESC LIMIT 100 OFFSET ?1').bind(offset).all()).results:[];
|
|
148
|
+
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])));
|
|
149
|
+
return html(`<h1>Your studio's AI connections</h1><p>MCP address: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public')} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
150
|
+
}
|
|
151
|
+
return json({error:'not found'},404);
|
|
152
|
+
}},
|
|
153
|
+
});
|
|
154
|
+
if(path==='/mcp'&&!request.headers.has('authorization')){
|
|
155
|
+
// Public tools remain available without an account. Clients can discover OAuth metadata directly.
|
|
156
|
+
return api(request,scoped,ctx);
|
|
157
|
+
}
|
|
158
|
+
return provider.fetch(request,scoped,ctx);
|
|
159
|
+
}
|
package/worker/office-page.mjs
CHANGED
|
@@ -937,7 +937,7 @@ export async function officePage(cat, headers = {}) {
|
|
|
937
937
|
const name = cat.studio?.name ?? 'Studio';
|
|
938
938
|
const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
|
|
939
939
|
const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
|
|
940
|
-
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
940
|
+
<div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a><a href="/_studio/office/connections">AI connections</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
|
|
941
941
|
<form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
|
|
942
942
|
<form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
|
|
943
943
|
<select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
|
package/worker/office.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { selling } from './extensions.mjs';
|
|
2
2
|
import { purchaseOrderIfPresent } from './shop.mjs';
|
|
3
|
+
import { toolIdentity } from './tool-identity.mjs';
|
|
3
4
|
/**
|
|
4
5
|
* THE STUDIO'S BACK OFFICE (@homie-rocks/studio 0.13.0): the studio's owner, and the owner's AI, see and run the
|
|
5
6
|
* studio's live games. Everything lives in the studio's own Worker and D1; homie.rocks stores none of it.
|
|
@@ -1196,7 +1197,7 @@ async function invitesApi(request, env, url, cat, body, who) {
|
|
|
1196
1197
|
async function api(request, env, url, cat) {
|
|
1197
1198
|
const path = url.pathname;
|
|
1198
1199
|
// An office key, the owner's signed-in browser, or the owner's own player account (a passkey).
|
|
1199
|
-
let who = await ownerAllowed(request, env, { kinds: ['office', 'session'] });
|
|
1200
|
+
let who = toolIdentity(request)?.owner ? 'office' : await ownerAllowed(request, env, { kinds: ['office', 'session'] });
|
|
1200
1201
|
if (!who && (await isOwner(request, env))) who = 'session';
|
|
1201
1202
|
if (!who) {
|
|
1202
1203
|
return json({ ok: false, error: 'owner-only', message: 'The back office is the studio owner\'s. In the studio folder, `npx --no-install homie-studio office key` gives the owner\'s AI a key; `npx --no-install homie-studio office link` signs the owner\'s browser in.' }, 401, { 'www-authenticate': 'Bearer' });
|
package/worker/purchase-mcp.mjs
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { purchaseResponse } from './purchase-response.mjs';
|
|
2
2
|
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
3
3
|
/** The SDK owns protocol negotiation, JSON-RPC errors and stateless HTTP transport. */
|
|
4
|
-
import { Server } from '
|
|
5
|
-
import { WebStandardStreamableHTTPServerTransport } from '
|
|
6
|
-
import { CallToolRequestSchema, ListToolsRequestSchema } from '
|
|
4
|
+
import { Server } from 'mcp-purchase-sdk/server/index.js';
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from 'mcp-purchase-sdk/server/webStandardStreamableHttp.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from 'mcp-purchase-sdk/types.js';
|
|
7
7
|
import { machineResource } from './purchase-machine.mjs';
|
|
8
8
|
export const purchaseSchema = {
|
|
9
9
|
type: 'object', required: ['kind', 'resource', 'version', 'buyer', 'claim', 'offerVersion'],
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
2
|
+
import {signControl} from './office.mjs';
|
|
3
|
+
import {allowed} from './mcp-tools.mjs';
|
|
4
|
+
/** A tool must declare each external event and its schema; it cannot impersonate player input. */
|
|
5
|
+
export async function sendToolEvent(env,caller,definition,{game,room,event,data}) {
|
|
6
|
+
const declaration=definition.events?.[event];
|
|
7
|
+
if(!declaration||declaration.game!==game||!/^external:[a-z][a-z0-9_-]{0,40}$/.test(event)||!/^[A-Za-z0-9_-]{1,32}$/.test(room))throw new Error('event not declared');
|
|
8
|
+
if(!await allowed(declaration.audience??'owner',caller,env))throw new Error('event permission denied');
|
|
9
|
+
if(!new CfWorkerJsonSchemaValidator().getValidator(declaration.schema)(data).valid)throw new Error('event input invalid');
|
|
10
|
+
if(JSON.stringify(data).length>8192)throw new Error('event too large');
|
|
11
|
+
const ctl=await signControl(env,{op:'mcp-event',game,room,args:{event,data,person:caller.id}});
|
|
12
|
+
const response=await env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`)).fetch(`https://table/__mcp-event?game=${game}&room=${room}`,{method:'POST',body:JSON.stringify(ctl)});
|
|
13
|
+
const result=await response.json();if(!response.ok)throw new Error('room refused event');return result;
|
|
14
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Internal request authority. HTTP headers cannot manufacture this identity. */
|
|
2
|
+
const identities = new WeakMap();
|
|
3
|
+
export function withToolIdentity(request, identity) { identities.set(request, identity); return request; }
|
|
4
|
+
export function toolIdentity(request) { return identities.get(request) ?? null; }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import {allowed,services} from './mcp-tools.mjs';
|
|
2
|
+
import {rateLimit,audit,callsPerMinute} from './mcp-store.mjs';
|
|
3
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
4
|
+
/** HMAC-SHA256(timestamp + '.' + delivery id + '.' + exact request body). */
|
|
5
|
+
export async function toolWebhook(request,env,{cat,definitions}) {
|
|
6
|
+
const name=new URL(request.url).pathname.split('/').pop(),tool=definitions.find(t=>t.name===name&&t.webhook);
|
|
7
|
+
const fail=(status)=>Response.json({ok:false},{status});
|
|
8
|
+
if(!tool||request.method!=='POST')return fail(404);
|
|
9
|
+
const timestamp=request.headers.get('x-studio-timestamp')??'',delivery=request.headers.get('x-studio-delivery')??'',signature=request.headers.get('x-studio-signature')??'';
|
|
10
|
+
if(!/^\d{10}$/.test(timestamp)||Math.abs(Date.now()/1000-Number(timestamp))>300||!/^[a-zA-Z0-9_-]{8,100}$/.test(delivery)||!/^[a-f0-9]{64}$/.test(signature))return fail(401);
|
|
11
|
+
const secret=env[tool.webhook.secret];if(typeof secret!=='string'||secret.length<32)return fail(503);
|
|
12
|
+
const body=await request.text();if(body.length>65536)return fail(413);
|
|
13
|
+
const key=await crypto.subtle.importKey('raw',new TextEncoder().encode(secret),{name:'HMAC',hash:'SHA-256'},false,['verify']);
|
|
14
|
+
const bytes=Uint8Array.from(signature.match(/../g),b=>parseInt(b,16));
|
|
15
|
+
if(!await crypto.subtle.verify('HMAC',key,bytes,new TextEncoder().encode(`${timestamp}.${delivery}.${body}`)))return fail(401);
|
|
16
|
+
const person=await env.DB.prepare('SELECT id,name,owner,guest FROM players WHERE id=?1').bind(tool.webhook.person).first();
|
|
17
|
+
const caller={id:person?.id,owner:person?.owner===1,client:'webhook:'+name};let outcome='denied';
|
|
18
|
+
try{
|
|
19
|
+
if(!person||person.guest||!await allowed(tool.audience,caller,env))return fail(403);
|
|
20
|
+
const limit=callsPerMinute(cat);
|
|
21
|
+
if(!await rateLimit(env,caller.client,limit)){outcome='limited';return fail(429);}
|
|
22
|
+
const value=JSON.parse(body),validate=new CfWorkerJsonSchemaValidator().getValidator(tool.inputSchema);
|
|
23
|
+
if(!validate(value).valid){outcome='invalid';return fail(400);}
|
|
24
|
+
const result=await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(`delivery:${name}:${delivery}`,'true',Math.floor(Date.now()/1000)+600).run();
|
|
25
|
+
if(!result.meta.changes){outcome='replay';return fail(409);}
|
|
26
|
+
await tool.handler(value,services(env,cat,new URL(request.url).origin,caller,tool.namespace??tool.name,tool));outcome='ok';return Response.json({ok:true});
|
|
27
|
+
}catch{outcome='error';return fail(400);}finally{await audit(env,caller,name,outcome);}
|
|
28
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.39.0';
|