@homie-rocks/studio 0.56.0 → 0.58.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 +55 -1
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/docs/studio-to-studio.md +248 -0
- package/docs/toolkit-place-patterns.md +16 -0
- package/functions/FUNCTIONS.md +9 -0
- package/lib/cloudflare.mjs +3 -0
- package/lib/needs-headless.mjs +1 -1
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +84 -0
- package/netplay/NETPLAY.md +7 -0
- package/package.json +1 -1
- package/parts/README.md +28 -0
- package/parts/catalogue.json +236 -0
- package/parts/owner-action-checklist/LICENSE +202 -0
- package/parts/owner-action-checklist/NOTICE +3 -0
- package/parts/owner-action-checklist/README.md +68 -0
- package/parts/owner-action-checklist/part.json +95 -0
- package/parts/owner-action-checklist/preview/index.html +16 -0
- package/parts/owner-action-checklist/src/handler.mjs +44 -0
- package/parts/owner-action-checklist/src/index.mjs +96 -0
- package/parts/owner-action-checklist/src/style.css +3 -0
- package/parts/owner-action-checklist/tuning.json +3 -0
- package/parts/terrain-route-map/LICENSE +202 -0
- package/parts/terrain-route-map/NOTICE +3 -0
- package/parts/terrain-route-map/README.md +67 -0
- package/parts/terrain-route-map/THIRD-PARTY-LICENSE +21 -0
- package/parts/terrain-route-map/part.json +133 -0
- package/parts/terrain-route-map/preview/demo.js +4127 -0
- package/parts/terrain-route-map/preview/heightmap.png +0 -0
- package/parts/terrain-route-map/preview/index.html +1 -0
- package/parts/terrain-route-map/preview/main.mjs +4 -0
- package/parts/terrain-route-map/preview/route.json +1 -0
- package/parts/terrain-route-map/src/data.mjs +81 -0
- package/parts/terrain-route-map/src/index.mjs +131 -0
- package/parts/terrain-route-map/src/style.css +3 -0
- package/parts/terrain-route-map/tuning.json +5 -0
- package/tools/TOOLS.md +16 -0
- package/tools/tools.ts +15 -2
- package/worker/booking-availability.mjs +35 -0
- package/worker/booking-calendar.mjs +15 -2
- 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/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +25 -2
- package/worker/mcp.mjs +43 -8
- package/worker/room.mjs +29 -0
- package/worker/routes.mjs +1 -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/version.mjs +1 -1
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/room.mjs
CHANGED
|
@@ -272,6 +272,7 @@ export class NetRoom {
|
|
|
272
272
|
this.lastSnap = snap;
|
|
273
273
|
if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
|
|
274
274
|
if (c) c.lastSnapAt = now;
|
|
275
|
+
this.retryBrowserSeats(c);
|
|
275
276
|
this.stats.snaps += 1;
|
|
276
277
|
this.snapTimes.push(now);
|
|
277
278
|
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
@@ -388,6 +389,34 @@ export class NetRoom {
|
|
|
388
389
|
this.tellWatchers();
|
|
389
390
|
}
|
|
390
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
|
+
|
|
391
420
|
hostCaps(c, m) {
|
|
392
421
|
// The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
|
|
393
422
|
if (!Array.isArray(m.caps)) return;
|
package/worker/routes.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export default async function route(request, env, ctx) {
|
|
|
56
56
|
|
|
57
57
|
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
58
58
|
|
|
59
|
-
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') {
|
|
60
60
|
const { remoteMcp } = await import('./mcp.mjs');
|
|
61
61
|
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
62
62
|
}
|
|
@@ -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
|
+
}
|
|
@@ -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));}
|
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.58.0';
|