@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
|
Binary file
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Terrain route map</title><link rel="stylesheet" href="../src/style.css"><style>body{margin:0;background:#091e28;color:#f0eedf;font-family:system-ui;padding:20px}header{display:flex;justify-content:space-between;align-items:end;gap:20px;max-width:1800px;margin:0 auto 20px}small{color:#f4d47b;letter-spacing:.14em}h1{font-size:clamp(2rem,5vw,3.5rem);letter-spacing:-.05em;margin:8px 0}p{color:#a7c0c7;margin:0;max-width:28rem}#map{max-width:1800px;margin:auto;height:70vh;min-height:440px}@media(max-width:600px){header{display:block}#map{height:65vh}}</style><header><div><small>FIELD ATLAS / 01</small><h1>The coastal path.</h1></div><p>From sheltered harbour to high ridge. Drag to explore, pinch to zoom, or use the camera buttons.</p></header><main id="map" aria-label="Coastal path terrain"></main><script type="module" src="./demo.js"></script></html>
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import {mountTerrainMap} from '../src/index.mjs';
|
|
2
|
+
const data=await (await fetch(new URL('./route.json',import.meta.url))).json();
|
|
3
|
+
data.heightmap=new URL('./heightmap.png',import.meta.url).href;
|
|
4
|
+
try { window.map=await mountTerrainMap(document.querySelector('main'),data); } catch(error) { document.querySelector('main').textContent=`Map could not load: ${error.message}`; throw error; }
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bounds":[0,0,100,100],"height":42,"seaLevel":6,"heightmap":"heightmap.png","route":{"type":"LineString","coordinates":[[20,72],[30,66],[40,58],[48,51],[58,45],[67,36],[75,26]]},"markers":[{"coordinates":[20,72],"label":"Harbour"},{"coordinates":[48,51],"label":"Lookout"},{"coordinates":[75,26],"label":"High ridge"}]}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
export function validateMap(data, {baseURL = globalThis.location?.href, allowedOrigins = []} = {}) {
|
|
2
|
+
validateHeightmapURL(data.heightmap, {baseURL, allowedOrigins});
|
|
3
|
+
const {bounds, route, markers = [], height = 28, seaLevel = 6} = data;
|
|
4
|
+
if (!Array.isArray(bounds) || bounds.length !== 4 || !bounds.every(Number.isFinite) || bounds[2] <= bounds[0] || bounds[3] <= bounds[1]) throw new Error('bounds must be [minX,minZ,maxX,maxZ]');
|
|
5
|
+
const point = p => Array.isArray(p) && p.length === 2 && p.every(Number.isFinite) && p[0]>=bounds[0] && p[0]<=bounds[2] && p[1]>=bounds[1] && p[1]<=bounds[3];
|
|
6
|
+
if (route?.type !== 'LineString' || !Array.isArray(route.coordinates) || route.coordinates.length < 2 || route.coordinates.length > 2048 || !route.coordinates.every(point)) throw new Error('route must be a bounded LineString of 2–2048 points');
|
|
7
|
+
if (route.coordinates.some((p,i,a)=> i && p[0]===a[i-1][0] && p[1]===a[i-1][1])) throw new Error('Consecutive route points must differ');
|
|
8
|
+
if (!Number.isFinite(height) || height <= 0 || !Number.isFinite(seaLevel) || seaLevel < 0 || seaLevel >= height) throw new Error('Invalid height or sea level');
|
|
9
|
+
if (!Array.isArray(markers) || markers.length > 32 || markers.some(m=>!point(m.coordinates)||typeof m.label!=='string'||!m.label.trim()||m.label.length>80)) throw new Error('Invalid markers (maximum 32)');
|
|
10
|
+
return {...data, markers, height, seaLevel};
|
|
11
|
+
}
|
|
12
|
+
/** Red channel, row zero = minZ. Bilinear interpolation; pixel endpoints span bounds. */
|
|
13
|
+
export function heightSampler({width, height, data}, bounds, amplitude) {
|
|
14
|
+
if (!Number.isInteger(width)||!Number.isInteger(height)||width<2||height<2||data.length!==width*height*4) throw new Error('Invalid RGBA heightmap');
|
|
15
|
+
return (x,z) => {
|
|
16
|
+
const u=Math.max(0,Math.min(1,(x-bounds[0])/(bounds[2]-bounds[0])))*(width-1);
|
|
17
|
+
const v=Math.max(0,Math.min(1,(z-bounds[1])/(bounds[3]-bounds[1])))*(height-1);
|
|
18
|
+
const a=Math.floor(u), b=Math.floor(v), fx=u-a, fz=v-b;
|
|
19
|
+
const sample=(i,j)=>data[(Math.min(j,height-1)*width+Math.min(i,width-1))*4]/255*amplitude;
|
|
20
|
+
return (sample(a,b)*(1-fx)+sample(a+1,b)*fx)*(1-fz)+(sample(a,b+1)*(1-fx)+sample(a+1,b+1)*fx)*fz;
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Stable screen-space packing. Hidden overflow remains in the accessible marker list. */
|
|
25
|
+
export function placeLabels(points, width, height) {
|
|
26
|
+
const placed=[];
|
|
27
|
+
return points.map(({x,y,w,h})=>{
|
|
28
|
+
const left=Math.max(8,Math.min(width-w-8,x-w/2));
|
|
29
|
+
for(let step=0;step<height;step+=h+8){
|
|
30
|
+
for(const sign of [-1,1]){
|
|
31
|
+
const top=Math.max(8,Math.min(height-h-112,y-72+step*sign));
|
|
32
|
+
if(top<8 || placed.some(b=>left<b.x+b.w+8&&left+w+8>b.x&&top<b.y+b.h+8&&top+h+8>b.y))continue;
|
|
33
|
+
const box={x:left,y:top,w,h};placed.push(box);return box;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
/** Ignore startup/hidden gaps; sustained slow frames reduce the GPU budget once. */
|
|
40
|
+
export function qualityMonitor({threshold=28,samples=90}={}) {
|
|
41
|
+
let count=0,slow=0;
|
|
42
|
+
return dt=>{if(dt<=0||dt>250)return false;count++;if(dt>threshold)slow++;return count>=samples&&slow/count>.25;};
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** PNG only: verify signature and IHDR dimensions before invoking an image decoder. */
|
|
46
|
+
export function heightmapDimensions(bytes) {
|
|
47
|
+
const signature = [137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82];
|
|
48
|
+
if (bytes.length < 24 || signature.some((value,i) => bytes[i] !== value)) throw new Error('Expected a PNG heightmap');
|
|
49
|
+
const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
|
50
|
+
const width = view.getUint32(16), height = view.getUint32(20);
|
|
51
|
+
if (width < 2 || height < 2 || width > 2048 || height > 2048) throw new Error('Heightmap dimensions must be 2–2048 pixels');
|
|
52
|
+
return {width, height};
|
|
53
|
+
}
|
|
54
|
+
export function validateHeightmapURL(value, {baseURL, allowedOrigins = []} = {}) {
|
|
55
|
+
if (typeof value !== 'string' || !value.trim()) throw new Error('Invalid heightmap URL');
|
|
56
|
+
// Without a browser origin, validation permits relative paths only by default.
|
|
57
|
+
const base = new URL(baseURL || 'https://relative.invalid/');
|
|
58
|
+
const url = new URL(value, base);
|
|
59
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || !/\.png$/i.test(url.pathname) ||
|
|
60
|
+
(url.origin !== base.origin && !allowedOrigins.includes(url.origin)) ||
|
|
61
|
+
(!baseURL && /^(?:[a-z][a-z0-9+.-]*:|[\/\\]{2})/i.test(value) && !allowedOrigins.includes(url.origin))) throw new Error('Heightmap must be a same-origin or explicitly allowed PNG URL');
|
|
62
|
+
return url.href;
|
|
63
|
+
}
|
|
64
|
+
export async function loadHeightmap(value, options = {}) {
|
|
65
|
+
const url = validateHeightmapURL(value, options);
|
|
66
|
+
const response = await fetch(url, {redirect:'error', credentials:'same-origin'});
|
|
67
|
+
if (!response.ok || response.headers.get('content-type')?.split(';')[0].trim() !== 'image/png') throw new Error('Expected a PNG response');
|
|
68
|
+
const reader = response.body.getReader(), chunks = []; let size = 0;
|
|
69
|
+
try {
|
|
70
|
+
for (;;) {
|
|
71
|
+
const {done, value:chunk} = await reader.read(); if (done) break;
|
|
72
|
+
size += chunk.byteLength;
|
|
73
|
+
if (size > 16 * 1024 * 1024) throw new Error('Heightmap exceeds download budget');
|
|
74
|
+
chunks.push(chunk);
|
|
75
|
+
}
|
|
76
|
+
} catch (error) {await reader.cancel(); throw error;}
|
|
77
|
+
const bytes = new Uint8Array(size); let offset = 0;
|
|
78
|
+
for (const chunk of chunks) {bytes.set(chunk,offset); offset += chunk.length;}
|
|
79
|
+
const {width,height} = heightmapDimensions(bytes);
|
|
80
|
+
return createImageBitmap(new Blob([bytes], {type:'image/png'}), {resizeWidth:width, resizeHeight:height});
|
|
81
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import * as THREE from 'three';
|
|
2
|
+
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
|
|
3
|
+
import {validateMap, loadHeightmap, heightSampler, placeLabels, qualityMonitor} from './data.mjs';
|
|
4
|
+
/** Owns only its canvas, controls and GPU resources. Call dispose when leaving the view. */
|
|
5
|
+
export async function mountTerrainMap(root, input, {segments=192, pixelRatio=1.5, quality='auto', allowedOrigins=[]}={}) {
|
|
6
|
+
if(!['auto','full','lite'].includes(quality))throw new Error('Invalid quality');
|
|
7
|
+
const imageOptions={baseURL:location.href, allowedOrigins};
|
|
8
|
+
const data=validateMap(input,imageOptions);
|
|
9
|
+
if (!Number.isInteger(segments)||segments<8||segments>256||!Number.isFinite(pixelRatio)||pixelRatio<.5||pixelRatio>2) throw new Error('Invalid rendering budget');
|
|
10
|
+
const spanBudget=Math.max(data.bounds[2]-data.bounds[0],data.bounds[3]-data.bounds[1]);
|
|
11
|
+
const estimatedPoints=data.route.coordinates.slice(1).reduce((sum,b,i)=>{const a=data.route.coordinates[i];return sum+Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(spanBudget/segments/2)));},1);
|
|
12
|
+
if(estimatedPoints>8192) throw new Error('Route exceeds rendering budget');
|
|
13
|
+
const image = await loadHeightmap(data.heightmap,imageOptions);
|
|
14
|
+
const sampling=document.createElement('canvas'); sampling.width=image.width; sampling.height=image.height;
|
|
15
|
+
const context=sampling.getContext('2d',{willReadFrequently:true}); context.drawImage(image,0,0);
|
|
16
|
+
image.close();
|
|
17
|
+
const rawSample=heightSampler(context.getImageData(0,0,image.width,image.height),data.bounds,data.height);
|
|
18
|
+
const sample=(x,z)=>(rawSample(x,z)*4+rawSample(x-.5,z)+rawSample(x+.5,z)+rawSample(x,z-.5)+rawSample(x,z+.5))/8;
|
|
19
|
+
const [x0,z0,x1,z1]=data.bounds, width=x1-x0, depth=z1-z0, span=Math.max(width,depth);
|
|
20
|
+
const scene=new THREE.Scene(); scene.background=new THREE.Color('#9ebbc5'); scene.fog=new THREE.FogExp2('#9ebbc5',.0009);
|
|
21
|
+
const reduced=matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
22
|
+
let lite=quality==='lite', intro=!reduced; const monitor=qualityMonitor();
|
|
23
|
+
const camera=new THREE.PerspectiveCamera(40,1,span/1000,span*12);
|
|
24
|
+
const renderer=new THREE.WebGLRenderer({antialias:true,alpha:false}); renderer.setPixelRatio(Math.min(devicePixelRatio,pixelRatio));
|
|
25
|
+
renderer.outputColorSpace=THREE.SRGBColorSpace; renderer.toneMapping=THREE.ACESFilmicToneMapping; renderer.toneMappingExposure=1.15;
|
|
26
|
+
const canvas=renderer.domElement; canvas.tabIndex=0; canvas.setAttribute('role','img'); canvas.setAttribute('aria-label','Terrain map. Drag to orbit, pinch to zoom. Arrow keys rotate, plus and minus zoom, Home resets.');
|
|
27
|
+
root.classList.add('terrain-map'); root.append(canvas);
|
|
28
|
+
const controls=new OrbitControls(camera,canvas); controls.enableDamping=false; controls.enablePan=false; controls.minDistance=span*.3; controls.maxDistance=span*3; controls.maxPolarAngle=Math.PI*.46;
|
|
29
|
+
controls.target.set((x0+x1)/2,data.seaLevel,(z0+z1)/2);
|
|
30
|
+
function reset(){const fit=Math.max(1,1/Math.max(.35,camera.aspect));camera.position.set((x0+x1)/2+span*.48*fit,span*.65*fit,(z0+z1)/2+span*.78*fit); controls.update();}
|
|
31
|
+
reset();
|
|
32
|
+
scene.add(new THREE.HemisphereLight('#bce2ff','#293d32',1.7));
|
|
33
|
+
const sun=new THREE.DirectionalLight('#ffe2ad',3.1); sun.position.set(-span,span*.9,span); scene.add(sun);
|
|
34
|
+
const sky=new THREE.Mesh(new THREE.SphereGeometry(span*5,16,12),new THREE.ShaderMaterial({side:THREE.BackSide,depthWrite:false,uniforms:{},vertexShader:`varying vec3 direction;void main(){direction=position;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,fragmentShader:`varying vec3 direction;void main(){float h=smoothstep(-.1,.7,normalize(direction).y);gl_FragColor=vec4(mix(vec3(.62,.73,.77),vec3(.12,.32,.49),h),1.);}`}));sky.position.copy(controls.target);scene.add(sky);
|
|
35
|
+
const geometry=new THREE.PlaneGeometry(width,depth,segments,segments); geometry.rotateX(-Math.PI/2); geometry.translate((x0+x1)/2,0,(z0+z1)/2);
|
|
36
|
+
const positions=geometry.attributes.position; const colors=[];
|
|
37
|
+
for(let i=0;i<positions.count;i++) {
|
|
38
|
+
const y=sample(positions.getX(i),positions.getZ(i)); positions.setY(i,y);
|
|
39
|
+
const x=positions.getX(i),z=positions.getZ(i);
|
|
40
|
+
const slope=Math.hypot(sample(x+1,z)-sample(x-1,z),sample(x,z+1)-sample(x,z-1));
|
|
41
|
+
const color=new THREE.Color('#3e6951').lerp(new THREE.Color('#a2a493'),Math.min(1,Math.max(slope*.35,(y/data.height-.5)*2)));
|
|
42
|
+
color.lerp(new THREE.Color('#d7c99d'),1-THREE.MathUtils.smoothstep(y,data.seaLevel+.2,data.seaLevel+2.5));
|
|
43
|
+
const cavity=Math.max(0,(sample(x+3,z)+sample(x-3,z)+sample(x,z+3)+sample(x,z-3))/4-y);
|
|
44
|
+
color.multiplyScalar((.96+.04*Math.sin(x*.7+Math.sin(z*.3))*Math.cos(z*.8))*(1-Math.min(.3,cavity*.16))); colors.push(color.r,color.g,color.b);
|
|
45
|
+
}
|
|
46
|
+
geometry.setAttribute('color',new THREE.Float32BufferAttribute(colors,3)); geometry.computeVertexNormals();
|
|
47
|
+
function useLite(){lite=true;renderer.setPixelRatio(Math.min(1,pixelRatio));renderer.setSize(root.clientWidth,root.clientHeight,false);
|
|
48
|
+
// Reuse the vertex buffer, drawing every second grid cell. No second mesh allocation.
|
|
49
|
+
if(segments%2===0){const indices=[];for(let z=0;z<segments;z+=2)for(let x=0;x<segments;x+=2){const a=z*(segments+1)+x,b=a+2,c=a+2*(segments+1),d=c+2;indices.push(a,c,b,b,c,d);}geometry.setIndex(indices);}
|
|
50
|
+
}
|
|
51
|
+
if(lite)useLite();
|
|
52
|
+
scene.add(new THREE.Mesh(geometry,new THREE.MeshStandardMaterial({vertexColors:true,roughness:1,flatShading:false})));
|
|
53
|
+
const heightTexture=new THREE.CanvasTexture(sampling); heightTexture.flipY=false;
|
|
54
|
+
const waterMaterial=new THREE.ShaderMaterial({uniforms:{time:{value:0},heights:{value:heightTexture},bounds:{value:new THREE.Vector4(x0,z0,width,depth)},amplitude:{value:data.height},sea:{value:data.seaLevel},lite:{value:lite?1:0}},
|
|
55
|
+
vertexShader:`varying vec3 world; void main(){world=(modelMatrix*vec4(position,1.)).xyz;gl_Position=projectionMatrix*viewMatrix*vec4(world,1.);}`,
|
|
56
|
+
fragmentShader:`precision highp float; varying vec3 world; uniform sampler2D heights; uniform vec4 bounds; uniform float amplitude,sea,time,lite;
|
|
57
|
+
void main(){vec2 uv=(world.xz-bounds.xy)/bounds.zw;float inside=smoothstep(0.,.08,uv.x)*smoothstep(0.,.08,uv.y)*(1.-smoothstep(.92,1.,uv.x))*(1.-smoothstep(.92,1.,uv.y));
|
|
58
|
+
float depth=max(0.,sea-texture2D(heights,clamp(uv,0.,1.)).r*amplitude*inside);
|
|
59
|
+
vec3 col=mix(vec3(.14,.58,.56),vec3(.025,.18,.27),smoothstep(0.,3.5,depth));
|
|
60
|
+
float wave=sin(world.x*.7+world.z*.55+sin(world.z*.13)*2.-time*1.1)+sin(world.z*1.3-world.x*.4+sin(world.x*.17)*3.+time*.8);
|
|
61
|
+
float foam=(1.-smoothstep(.12,1.1,depth))*(.55+.45*sin(depth*15.-time*2.+wave*.5));
|
|
62
|
+
col=mix(col,vec3(.84,.93,.83),foam*.8);col+=pow(max(0.,wave*.5),12.)*.035*(1.-lite*.7);
|
|
63
|
+
float haze=1.-exp(-distance(cameraPosition,world)*.0009);col=mix(col,vec3(.35,.52,.60),haze);
|
|
64
|
+
gl_FragColor=vec4(col,1.); #include <tonemapping_fragment>
|
|
65
|
+
#include <colorspace_fragment>
|
|
66
|
+
}`.replace(' #include','\n#include')});
|
|
67
|
+
const water=new THREE.Mesh(new THREE.PlaneGeometry(width*12,depth*12),waterMaterial); water.rotation.x=-Math.PI/2; water.position.set((x0+x1)/2,data.seaLevel,(z0+z1)/2); scene.add(water);
|
|
68
|
+
// Densify straight segments before draping: avoids spline overshoot and tubes cutting hills.
|
|
69
|
+
const points=[];
|
|
70
|
+
for(let i=1;i<data.route.coordinates.length;i++) {
|
|
71
|
+
const a=data.route.coordinates[i-1],b=data.route.coordinates[i];
|
|
72
|
+
const steps=Math.max(1,Math.ceil(Math.hypot(b[0]-a[0],b[1]-a[1])/(span/segments/2)));
|
|
73
|
+
for(let j=0;j<steps;j++){const t=j/steps,x=a[0]+(b[0]-a[0])*t,z=a[1]+(b[1]-a[1])*t;points.push(new THREE.Vector3(x,Math.max(sample(x,z),data.seaLevel)+span*.008,z));}
|
|
74
|
+
}
|
|
75
|
+
const last=data.route.coordinates.at(-1); points.push(new THREE.Vector3(last[0],Math.max(sample(...last),data.seaLevel)+span*.008,last[1]));
|
|
76
|
+
const path=new THREE.CurvePath(); for(let i=1;i<points.length;i++)path.add(new THREE.LineCurve3(points[i-1],points[i]));
|
|
77
|
+
scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.003,5,false),new THREE.MeshBasicMaterial({color:'#ffcd61'})));
|
|
78
|
+
scene.add(new THREE.Mesh(new THREE.TubeGeometry(path,Math.min(4096,points.length*2),span*.006,5,false),new THREE.MeshBasicMaterial({color:'#ffcc65',transparent:true,opacity:.12,depthWrite:false})));
|
|
79
|
+
const flowGeometry=new THREE.SphereGeometry(span*.004,6,4);
|
|
80
|
+
const flow=new THREE.InstancedMesh(flowGeometry,new THREE.MeshBasicMaterial({color:'#ffffff'}),12);scene.add(flow);
|
|
81
|
+
const flowMatrix=new THREE.Matrix4();
|
|
82
|
+
const labels=document.createElement('div');labels.className='terrain-labels';root.append(labels);
|
|
83
|
+
const labelPoints=data.markers.map((marker,index)=>{
|
|
84
|
+
const [x,z]=marker.coordinates,y=Math.max(sample(x,z),data.seaLevel);
|
|
85
|
+
const mesh=new THREE.Mesh(new THREE.CylinderGeometry(span*.003,span*.003,span*.035,8),new THREE.MeshBasicMaterial({color:'#fff2c4'}));mesh.position.set(x,y+span*.0175,z);scene.add(mesh);
|
|
86
|
+
const line=document.createElement('i');line.setAttribute('aria-hidden','true');labels.append(line);
|
|
87
|
+
const label=document.createElement('span');label.textContent=`${String(index+1).padStart(2,'0')} ${marker.label}`;labels.append(label);
|
|
88
|
+
return {label,line,position:new THREE.Vector3(x,y+span*.04,z)};
|
|
89
|
+
});
|
|
90
|
+
const accessible=document.createElement('ul');accessible.className='terrain-accessible';
|
|
91
|
+
for(const m of data.markers){const li=document.createElement('li');li.textContent=m.label;accessible.append(li);}root.append(accessible);
|
|
92
|
+
labels.setAttribute('aria-hidden','true');
|
|
93
|
+
let scheduled=0,disposed=false,visible=true,previous=0,start=0;
|
|
94
|
+
const projected=new THREE.Vector3();
|
|
95
|
+
function render(now){scheduled=0;if(disposed||!visible||document.hidden){previous=0;return;}
|
|
96
|
+
if(!start)start=now; const elapsed=(now-start)/1000;
|
|
97
|
+
if(quality==='auto'&&!lite&&previous&&elapsed>2&&monitor(now-previous)){useLite();waterMaterial.uniforms.lite.value=1;}
|
|
98
|
+
previous=now;waterMaterial.uniforms.time.value=reduced?0:elapsed;
|
|
99
|
+
if(intro){const t=Math.min(1,elapsed/2.2);reset();const offset=camera.position.clone().sub(controls.target);offset.applyAxisAngle(new THREE.Vector3(0,1,0),.15*(1-t)*(1-t));camera.position.copy(controls.target).add(offset);controls.update();if(t===1)intro=false;}
|
|
100
|
+
for(let i=0;i<12;i++){const p=path.getPoint(((reduced?0:elapsed*.035)+i/12)%1);flowMatrix.makeTranslation(p.x,p.y,p.z);flow.setMatrixAt(i,flowMatrix);}flow.instanceMatrix.needsUpdate=true;
|
|
101
|
+
renderer.render(scene,camera);
|
|
102
|
+
const projectedLabels=labelPoints.map(({label,position})=>{projected.copy(position).project(camera);return {x:(projected.x*.5+.5)*canvas.clientWidth,y:(-projected.y*.5+.5)*canvas.clientHeight,w:label.offsetWidth,h:label.offsetHeight,visible:projected.z<=1&&projected.z>=-1};});
|
|
103
|
+
const boxes=placeLabels(projectedLabels,canvas.clientWidth,canvas.clientHeight);
|
|
104
|
+
labelPoints.forEach(({label,line},i)=>{const box=boxes[i],p=projectedLabels[i];label.style.visibility=line.style.visibility=box&&p.visible?'visible':'hidden';if(!box)return;
|
|
105
|
+
label.style.transform=`translate(${box.x}px,${box.y}px)`;
|
|
106
|
+
const dx=box.x+box.w/2-p.x,dy=box.y+box.h-p.y;line.style.width=`${Math.hypot(dx,dy)}px`;line.style.transform=`translate(${p.x}px,${p.y}px) rotate(${Math.atan2(dy,dx)}rad)`;
|
|
107
|
+
});
|
|
108
|
+
if(!reduced)invalidate();
|
|
109
|
+
}
|
|
110
|
+
function invalidate(){if(!scheduled&&!disposed)scheduled=requestAnimationFrame(render);}
|
|
111
|
+
controls.addEventListener('start',()=>{intro=false;});
|
|
112
|
+
controls.addEventListener('change',invalidate);
|
|
113
|
+
let initialSize=true;
|
|
114
|
+
const resize=new ResizeObserver(()=>{const w=root.clientWidth,h=root.clientHeight;if(!w||!h)return;renderer.setSize(w,h,false);camera.aspect=w/h;camera.updateProjectionMatrix();if(initialSize){initialSize=false;reset();}invalidate();});resize.observe(root);
|
|
115
|
+
const intersection=new IntersectionObserver(entries=>{visible=entries[0].isIntersecting;if(visible)invalidate();});intersection.observe(root);
|
|
116
|
+
document.addEventListener('visibilitychange',invalidate);
|
|
117
|
+
function command(action){
|
|
118
|
+
intro=false;
|
|
119
|
+
const offset=camera.position.clone().sub(controls.target),s=new THREE.Spherical().setFromVector3(offset);
|
|
120
|
+
if(action==='left')s.theta-=.15; if(action==='right')s.theta+=.15;
|
|
121
|
+
if(action==='up')s.phi=Math.max(.15,s.phi-.1);if(action==='down')s.phi=Math.min(controls.maxPolarAngle,s.phi+.1);
|
|
122
|
+
if(action==='in')s.radius=Math.max(controls.minDistance,s.radius*.85);if(action==='out')s.radius=Math.min(controls.maxDistance,s.radius/ .85);
|
|
123
|
+
if(action==='reset')reset();else {camera.position.copy(controls.target).add(new THREE.Vector3().setFromSpherical(s));controls.update();}invalidate();
|
|
124
|
+
}
|
|
125
|
+
const keyActions={ArrowLeft:'left',ArrowRight:'right',ArrowUp:'up',ArrowDown:'down','+':'in','=':'in','-':'out',Home:'reset'};
|
|
126
|
+
function keydown(e){if(keyActions[e.key]){e.preventDefault();command(keyActions[e.key]);}}canvas.addEventListener('keydown',keydown);
|
|
127
|
+
const toolbar=document.createElement('div');toolbar.className='terrain-controls';toolbar.setAttribute('aria-label','Map camera');
|
|
128
|
+
for(const [action,label] of [['left','Rotate left'],['right','Rotate right'],['in','Zoom in'],['out','Zoom out'],['reset','Reset view']]){const button=document.createElement('button');button.type='button';button.textContent=label;button.onclick=()=>command(action);toolbar.append(button);}root.append(toolbar);
|
|
129
|
+
invalidate();
|
|
130
|
+
return {camera,renderer,command,get quality(){return lite?'lite':'full';},render:invalidate,dispose(){disposed=true;cancelAnimationFrame(scheduled);resize.disconnect();intersection.disconnect();document.removeEventListener('visibilitychange',invalidate);canvas.removeEventListener('keydown',keydown);controls.dispose();scene.traverse(o=>{o.geometry?.dispose();if(o.material)o.material.dispose();});heightTexture.dispose();renderer.dispose();canvas.remove();labels.remove();accessible.remove();toolbar.remove();}};
|
|
131
|
+
}
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
.terrain-map{position:relative;min-height:360px;height:100%;overflow:hidden;background:#102d39;border-radius:24px}.terrain-map canvas{width:100%;height:100%;display:block;touch-action:none}.terrain-map canvas:focus-visible{outline:3px solid #ffe9a6;outline-offset:-4px}.terrain-labels{position:absolute;inset:0;pointer-events:none;overflow:hidden}.terrain-labels span{position:absolute;top:0;left:0;background:#102329e8;border:1px solid #91b1ac;border-radius:8px;padding:5px 9px;color:#f5f2de;font:600 13px system-ui;white-space:nowrap}.terrain-labels [hidden]{display:none}.terrain-controls{position:absolute;bottom:12px;left:12px;right:12px;display:flex;justify-content:center;flex-wrap:wrap;gap:6px}.terrain-controls button{min-height:44px;padding:10px 12px;border:1px solid #91b1ac;background:#102329ed;color:#fff4cf;border-radius:10px;font:600 13px system-ui;cursor:pointer}.terrain-controls button:focus-visible{outline:3px solid #ffe9a6;outline-offset:2px}
|
|
2
|
+
.terrain-labels span{border:1px solid #e6d7a070;border-radius:5px;padding:9px 12px;background:#142f39ed;box-shadow:0 4px 18px #03192345;letter-spacing:.03em;font-size:12px}.terrain-labels i{position:absolute;left:0;top:0;height:1px;background:#ffe7a9b0;transform-origin:0 0}.terrain-accessible{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%)}@media(min-width:1400px){.terrain-labels span{font-size:18px;padding:12px 18px}.terrain-controls button{font-size:16px}}
|
|
3
|
+
.terrain-labels span{max-width:min(240px,calc(100% - 40px));white-space:normal;overflow-wrap:anywhere;box-sizing:border-box}
|
package/tools/TOOLS.md
CHANGED
|
@@ -193,3 +193,19 @@ Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
|
|
|
193
193
|
Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
|
|
194
194
|
token. An approved connection retains the account’s current app grants, not a copy
|
|
195
195
|
of its old permissions. Manage connections at `/_studio/office/connections`.
|
|
196
|
+
|
|
197
|
+
## Acting at another studio
|
|
198
|
+
|
|
199
|
+
Use `context.studios.listTools`, `call` and `send` for another studio. The official
|
|
200
|
+
MCP SDK handles transport and OAuth. Configure receiver-owned studio allowlists;
|
|
201
|
+
a named person's handle is an assertion, not the receiver's authenticated account.
|
|
202
|
+
Discover `studio_availability` before `studio_book`. For your own registered routes,
|
|
203
|
+
use `fetchSelf` to preserve the caller. Opt-in `rateLimit` shares a declared
|
|
204
|
+
Cloudflare binding between tools. See [studio-to-studio](../docs/studio-to-studio.md)
|
|
205
|
+
for configuration, trust boundaries, signed events and examples.
|
|
206
|
+
|
|
207
|
+
Studio callers use origin-hosted JWKS and private-key JWT; office Allow/Deny is
|
|
208
|
+
stored in D1. Pass `{assertedHandle:'guest-42'}`; never match this assertion to a
|
|
209
|
+
local account. Results and descriptions carry `{source,untrusted:true}`. Wrap them
|
|
210
|
+
as untrusted input before AI use. Anonymous MCP callers expose `clientAddressKey`,
|
|
211
|
+
which is their default rate-limit key; studio callers share `studio:<origin>`.
|
package/tools/tools.ts
CHANGED
|
@@ -1,13 +1,22 @@
|
|
|
1
|
+
import type {BookingAvailability} from '../apps/apps';
|
|
1
2
|
/** A studio tool runs as trusted code in its own Worker. Never import an environment binding. */
|
|
2
3
|
export type Audience = 'public' | 'signed-in' | 'owner' | { app: string; role: string };
|
|
4
|
+
export interface RemoteStudioSource { readonly origin: string; readonly authenticated: boolean }
|
|
5
|
+
export interface UntrustedStudioData { readonly source: RemoteStudioSource; readonly untrusted: true }
|
|
3
6
|
export interface ToolContext {
|
|
4
7
|
readonly purchase?: { readonly id: string };
|
|
5
|
-
readonly caller: { readonly id: string | null; readonly owner: boolean; readonly client: string | null; readonly roles: Readonly<Record<string, readonly string[]>> };
|
|
8
|
+
readonly caller: { readonly clientAddressKey?: string; readonly id: string | null; readonly owner: boolean; readonly client: string | null; readonly studio?: {readonly origin: string; readonly verified: true; readonly method: 'private_key_jwt'}; readonly actingFor?: {readonly assertedHandle: string; readonly verified: false; readonly assertedBy: string}; readonly roles: Readonly<Record<string, readonly string[]>> };
|
|
6
9
|
records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
|
|
7
10
|
office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
|
|
8
11
|
shop(): Promise<unknown>;
|
|
12
|
+
studios: {
|
|
13
|
+
send(origin: string, event: string, input: {id: string; data: unknown}): Promise<{id: string}>;
|
|
14
|
+
listTools(origin: string, options?: {cursor?: string; assertedHandle?: string}): Promise<UntrustedStudioData & {tools: Array<UntrustedStudioData & {name: string; description?: string; inputSchema: Record<string, unknown>}>; nextCursor?: string}>;
|
|
15
|
+
call(origin: string, tool: string, args?: Record<string, unknown>, options?: {assertedHandle?: string}): Promise<UntrustedStudioData & {isError: boolean; data?: unknown; content: unknown[]}>;
|
|
16
|
+
};
|
|
9
17
|
bookings: {
|
|
10
|
-
|
|
18
|
+
availability(args: {app: string; collection: string; after?: string; quantity?: number; option?: string; resource?: string; addons?: Record<string,number>}): Promise<BookingAvailability>;
|
|
19
|
+
calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string;exceptions?:Record<string,null|{time?:string;capacity?:number;durationMinutes?:number}>}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
|
|
11
20
|
hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
|
|
12
21
|
get(args: {id: string; token?: string}): Promise<BookingHold>;
|
|
13
22
|
confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
|
|
@@ -24,6 +33,10 @@ export interface ToolContext {
|
|
|
24
33
|
append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
|
|
25
34
|
list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
|
|
26
35
|
};
|
|
36
|
+
/** Dispatch a custom route with caller identity and local Origin; routes must still authorize. */
|
|
37
|
+
fetchSelf(path: string, init?: RequestInit): Promise<Response>;
|
|
38
|
+
/** Opt-in shared Cloudflare Rate Limiting binding. Defaults to studio origin, signed-in identity, or anonymous MCP clientAddressKey. */
|
|
39
|
+
rateLimit(name: string, key?: string): Promise<{success: boolean}>;
|
|
27
40
|
fetch: typeof fetch;
|
|
28
41
|
fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
|
|
29
42
|
ai(model: string, input: unknown): Promise<unknown>;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import {allocateResources,resourceSnapshot} from './booking-resources.mjs';
|
|
2
|
+
import {settingsOf,launchOf} from './office.mjs';
|
|
3
|
+
import {allowed} from './mcp-tools.mjs';
|
|
4
|
+
const fail=message=>Object.assign(new Error(message),{name:'StudioOperationError'});
|
|
5
|
+
/** A snapshot, never a reservation. Atomic holds recheck capacity at write time. */
|
|
6
|
+
export async function bookingAvailability(env,cat,caller,{app,collection,after='',quantity=1,option,resource,addons}={}) {
|
|
7
|
+
const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
|
|
8
|
+
if(!spec||typeof after!=='string'||after.length>64||!Number.isSafeInteger(quantity)||quantity<1)throw fail('Booking availability unavailable');
|
|
9
|
+
if(!caller.owner){
|
|
10
|
+
if(launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public')throw fail('Booking availability unavailable');
|
|
11
|
+
let readable=false;
|
|
12
|
+
for(const [role,rule] of Object.entries(meta.roles??{}))if(rule.can?.includes('read:'+spec.slots)&&(!rule.signIn||await allowed({app,role},caller,env)))readable=true;
|
|
13
|
+
if(!readable)throw fail('Booking availability unavailable');
|
|
14
|
+
}
|
|
15
|
+
const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
|
|
16
|
+
const rows=(await env.DB.prepare(`SELECT s.id,s.value,COALESCE((SELECT SUM(b.quantity) FROM shop_bookings b WHERE b.app=s.app AND b.slots=s.collection AND b.slot=s.id AND b.state IN ('held','confirmed')),0) used FROM app_records s WHERE s.app=?1 AND s.collection=?2 AND s.id>?3 ORDER BY s.id LIMIT 51`).bind(scope,spec.slots,after).all()).results;
|
|
17
|
+
const snapshot=await resourceSnapshot(env,scope,spec);
|
|
18
|
+
const items=[];
|
|
19
|
+
for(const row of rows.slice(0,50)){
|
|
20
|
+
const slot=JSON.parse(row.value),remaining=slot.capacity-row.used;
|
|
21
|
+
if(!Number.isSafeInteger(remaining)||remaining<quantity)continue;
|
|
22
|
+
const choices=option!==undefined?[option]:[...(!slot.requireOption?[undefined]:[]),...(slot.options??[]).map(o=>o.id)];
|
|
23
|
+
if(choices.length>64)throw fail('Availability supports at most 64 options per slot');
|
|
24
|
+
for(const choice of choices){
|
|
25
|
+
const booking={app,collection,slot:row.id,quantity,...(choice===undefined?{}:{option:choice}),...(resource?{resource}:{}),...(addons?{addons}:{})};
|
|
26
|
+
try{allocateResources(slot,spec,booking,snapshot);}catch(error){
|
|
27
|
+
if(/Slot unavailable|Choose a declared option|Choose an option|Unknown add-on|no declared resources|Preferred resource|Resource capacity unavailable|No resource matches/.test(error.message))continue;
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
const selected=slot.options?.find(o=>o.id===choice);
|
|
31
|
+
items.push({'@type':'Offer',identifier:JSON.stringify([row.id,choice??null]),...(selected?.name?{name:selected.name}:{}),availability:'https://schema.org/InStock',...(slot.start?{availabilityStarts:slot.start}:{}),...(slot.end?{availabilityEnds:slot.end}:{}),remaining,booking});
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return {convention:'studio.booking-availability/1',bookingTool:'studio_book',items,next:rows.length>50?rows[49].id:null};
|
|
35
|
+
}
|
|
@@ -11,15 +11,28 @@ function instant(day,time,format) {
|
|
|
11
11
|
return matches[0];
|
|
12
12
|
}
|
|
13
13
|
/** Inclusive season dates; each returned slot may span several local calendar days. */
|
|
14
|
-
export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
|
|
14
|
+
export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot',exceptions={}}) {
|
|
15
15
|
const start=date(startDate),end=date(endDate);
|
|
16
|
+
if(!exceptions||Array.isArray(exceptions)||typeof exceptions!=='object')throw new Error('Invalid calendar exceptions');
|
|
17
|
+
for(const [day,change] of Object.entries(exceptions)){
|
|
18
|
+
const at=date(day);
|
|
19
|
+
if(at<start||at>end||weekdays&&!weekdays.includes(new Date(at).getUTCDay()))throw new Error('Exception is outside the series');
|
|
20
|
+
if(change!==null&&(!change||typeof change!=='object'||Array.isArray(change)||Object.keys(change).some(k=>!['time','capacity','durationMinutes'].includes(k)||change[k]===undefined)))throw new Error('Invalid calendar exception');
|
|
21
|
+
}
|
|
16
22
|
if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
|
|
17
23
|
if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
|
|
18
24
|
if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
|
|
19
25
|
const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
|
|
20
26
|
for(let at=start;at<=end;at+=dayMs) {
|
|
21
27
|
if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
|
|
22
|
-
const day=new Date(at).toISOString().slice(0,10)
|
|
28
|
+
const day=new Date(at).toISOString().slice(0,10);
|
|
29
|
+
if(Object.hasOwn(exceptions,day)){
|
|
30
|
+
const change=exceptions[day];if(change===null)continue;
|
|
31
|
+
const [slot]=calendarSlots({startDate:day,endDate:day,timeZone,time,days:Object.hasOwn(change,'durationMinutes')?undefined:days,durationMinutes,capacity,resources,options,prefix,...change});
|
|
32
|
+
// Identity follows the series occurrence, even when its wall time changes.
|
|
33
|
+
slots.push({...slot,id:`${prefix}-${day}-${time.replace(':','')}`});continue;
|
|
34
|
+
}
|
|
35
|
+
const from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
|
|
23
36
|
slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
|
|
24
37
|
}
|
|
25
38
|
return slots;
|
package/worker/booking-holds.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {calendarSlots} from './booking-calendar.mjs';
|
|
2
|
-
import {
|
|
2
|
+
import {loadAndAllocateResources} from './booking-resources.mjs';
|
|
3
3
|
import {normalizeBookingArgs} from './booking-options.mjs';
|
|
4
4
|
import {BOOKINGS_MIGRATION} from './booking-store.mjs';
|
|
5
5
|
import {linkConfig,hasStripeKey} from './shop-links.mjs';
|
|
@@ -74,7 +74,7 @@ export function bookingHolds(env,caller,cat){
|
|
|
74
74
|
if(previous.app!==scope||previous.collection!==collection||previous.slot!==slot||previous.quantity!==quantity||JSON.stringify({...choices,...JSON.parse(previous.choices??'{}')})!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
|
|
75
75
|
return result(previous);
|
|
76
76
|
}
|
|
77
|
-
let allocation;try{allocation=await
|
|
77
|
+
let allocation;try{allocation=await loadAndAllocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
|
|
78
78
|
const secret=token?await hash(token):null;
|
|
79
79
|
try {await env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,state,updated_at,actor,token_hash,hold_until,allocation,choices)
|
|
80
80
|
SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10,?11,?12 WHERE NOT EXISTS(SELECT 1 FROM shop_bookings WHERE id=?1)`).bind(id,scope,collection,spec.slots,slot,quantity,Date.now(),caller.id??null,secret,expiresAt,JSON.stringify(allocation),JSON.stringify(choices)).run();}catch(error){if(/booking capacity unavailable|resource capacity unavailable/.test(error.message))throw refusal('That slot or resource capacity is unavailable. No place has been taken.',409);throw error;}
|
|
@@ -14,10 +14,20 @@ CREATE TRIGGER booking_resource_move BEFORE UPDATE OF slot,quantity ON shop_book
|
|
|
14
14
|
CREATE TRIGGER booking_resource_edit BEFORE UPDATE OF value ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) AND NEW.value!=OLD.value BEGIN SELECT RAISE(ABORT,'resource has active bookings; release or finish them before editing'); END;
|
|
15
15
|
CREATE TRIGGER booking_resource_delete BEFORE DELETE ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) BEGIN SELECT RAISE(ABORT,'resource has active bookings'); END;
|
|
16
16
|
`;
|
|
17
|
-
export async function
|
|
17
|
+
export async function resourceSnapshot(env,app,spec) {
|
|
18
|
+
if(!spec.resources)return {resources:new Map(),active:[]};
|
|
19
|
+
const resources=(await env.DB.prepare('SELECT id,value FROM app_records WHERE app=?1 AND collection=?2').bind(app,spec.resources).all()).results;
|
|
20
|
+
const active=(await env.DB.prepare("SELECT allocation FROM shop_bookings WHERE app=?1 AND state IN ('held','confirmed')").bind(app).all()).results.flatMap(r=>JSON.parse(r.allocation));
|
|
21
|
+
return {resources:new Map(resources.map(r=>[r.id,JSON.parse(r.value)])),active};
|
|
22
|
+
}
|
|
23
|
+
export async function loadAndAllocateResources(env,app,spec,slot,args) {
|
|
18
24
|
const row=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.slots,slot).first();
|
|
19
|
-
|
|
20
|
-
|
|
25
|
+
return allocateResources(row?JSON.parse(row.value):null,spec,args,await resourceSnapshot(env,app,spec));
|
|
26
|
+
}
|
|
27
|
+
/** Pure allocation against a page snapshot. Holds still enforce capacity atomically in D1. */
|
|
28
|
+
export function allocateResources(data,spec,args,{resources,active}) {
|
|
29
|
+
if(!data)throw new Error('Slot unavailable');
|
|
30
|
+
const option=args.option===undefined?null:data.options?.find(o=>o.id===args.option);
|
|
21
31
|
if(args.option!==undefined&&!option)throw new Error('Choose a declared option');
|
|
22
32
|
if(data.requireOption&&!option)throw new Error('Choose an option');
|
|
23
33
|
const parts=option?.components??(data.resources?[{resources:data.resources,skills:data.skills,attributes:data.attributes}]:[]);
|
|
@@ -31,7 +41,6 @@ export async function allocateResources(env,app,spec,slot,args) {
|
|
|
31
41
|
const start=Date.parse(data.start),end=Date.parse(data.end);
|
|
32
42
|
if(!Number.isFinite(start)||!Number.isFinite(end)||end<=start)throw new Error('Resource slot needs start and end timestamps');
|
|
33
43
|
if(components.length>32)throw new Error('A package supports at most 32 resource components');
|
|
34
|
-
const active=(await env.DB.prepare("SELECT allocation FROM shop_bookings WHERE app=?1 AND state IN ('held','confirmed')").bind(app).all()).results.flatMap(r=>JSON.parse(r.allocation));
|
|
35
44
|
const allocations=[];
|
|
36
45
|
for(const [index,c] of components.entries()) {
|
|
37
46
|
if(!Array.isArray(c.resources)||!c.resources.length||c.resources.length>64)throw new Error('Declare candidate resources');
|
|
@@ -41,8 +50,7 @@ export async function allocateResources(env,app,spec,slot,args) {
|
|
|
41
50
|
if(!Number.isSafeInteger(from)||!Number.isSafeInteger(to)||from<start||to>end||to<=from||!Number.isSafeInteger(units)||units<1)throw new Error('Invalid package resource interval or units');
|
|
42
51
|
let selected,qualified=false;
|
|
43
52
|
for(const resource of candidates) {
|
|
44
|
-
const
|
|
45
|
-
if(!record)continue;const r=JSON.parse(record.value);
|
|
53
|
+
const r=resources.get(resource);if(!r)continue;
|
|
46
54
|
if(!Number.isSafeInteger(r.capacity)||r.capacity<1||(c.skills??[]).some(skill=>!r.skills?.includes(skill))||Object.entries(c.attributes??{}).some(([k,v])=>r.attributes?.[k]!==v))continue;
|
|
47
55
|
qualified=true;const overlapping=[...active,...allocations].filter(a=>a.resource===resource&&a.collection===spec.resources&&a.start<to&&a.end>from);
|
|
48
56
|
if([from,...overlapping.map(a=>a.start).filter(t=>t>=from)].some(at=>units+overlapping.filter(a=>a.start<=at&&a.end>at).reduce((n,a)=>n+a.units,0)>r.capacity))continue;
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.0","sha256":"c8953383f91877ce12a615ce9c730f7d5fd4859acf285efe5132404ef70c8895"});
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {canonicalJson} from './canonical-json.mjs';
|
|
2
|
+
export const BUSINESS_EVENT_MAX_BYTES=8192;
|
|
2
3
|
export const BUSINESS_EVENTS_MIGRATION_FILE='0028_business_events.sql';
|
|
3
4
|
export const BUSINESS_EVENTS_MIGRATION=`
|
|
4
5
|
ALTER TABLE studio_events ADD COLUMN scope TEXT;
|
|
@@ -19,7 +20,7 @@ export async function publishBusinessEvent(env,caller,definition,namespace,{feed
|
|
|
19
20
|
if(typeof id!=='string'||!id.length||id.length>128)throw new Error('Event id must contain 1–128 characters');
|
|
20
21
|
const {CfWorkerJsonSchemaValidator}=await import('@modelcontextprotocol/server/validators/cf-worker');
|
|
21
22
|
if(!new CfWorkerJsonSchemaValidator().getValidator(declaration.schema)(data).valid)throw new Error('Event data does not match feed schema');
|
|
22
|
-
const text=canonicalJson(data);if(text.length>
|
|
23
|
+
const text=canonicalJson(data);if(new TextEncoder().encode(text).length>BUSINESS_EVENT_MAX_BYTES)throw new Error('Event exceeds 8192 bytes');
|
|
23
24
|
const key=JSON.stringify([namespace,feed,id]),digest=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(declaration.event+'\n'+text))),b=>b.toString(16).padStart(2,'0')).join('');
|
|
24
25
|
await env.DB.batch([
|
|
25
26
|
env.DB.prepare('INSERT OR IGNORE INTO studio_events(id,type,data,at,scope) SELECT ?1,?2,?3,?4,?5 WHERE NOT EXISTS (SELECT 1 FROM event_publications WHERE id=?1)').bind(key,declaration.event,text,Date.now(),caller.testScope??null),
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export async function rateLimit(env, caller, limit = null) {
|
|
|
42
42
|
return row.n <= limit;
|
|
43
43
|
}
|
|
44
44
|
export async function audit(env, caller, tool, outcome) {
|
|
45
|
-
await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.id ?? null,caller.client ?? null,tool,Date.now(),outcome).run();
|
|
45
|
+
await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.studio?(caller.actingFor?.assertedHandle??null):caller.id ?? null,caller.studio?.origin??caller.client ?? null,tool,Date.now(),outcome).run();
|
|
46
46
|
// Bounded storage, and no cleanup timer or writes on ordinary page traffic.
|
|
47
47
|
await env.DB.batch([
|
|
48
48
|
env.DB.prepare('DELETE FROM mcp_audit WHERE id <= (SELECT MAX(id)-10000 FROM mcp_audit)'),
|
|
@@ -55,3 +55,5 @@ export function callsPerMinute(cat) {
|
|
|
55
55
|
const value=cat.studio?.mcp?.callsPerMinute;
|
|
56
56
|
return Number.isSafeInteger(value)&&value>0?value:null;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
export const callerLimitKey=caller=>caller.studio?'studio:'+caller.studio.origin:caller.id??caller.clientAddressKey;
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {callerLimitKey} from './mcp-store.mjs';
|
|
2
|
+
import {bookingAvailability} from './booking-availability.mjs';
|
|
3
|
+
import {studiosClient} from './studios-client.mjs';
|
|
1
4
|
import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
|
|
2
5
|
import {bookingHolds} from './booking-holds.mjs';
|
|
3
6
|
import {toolDatabase} from './tool-database.mjs';
|
|
@@ -12,7 +15,7 @@ export const object = (properties = {}, required = []) => ({type:'object',proper
|
|
|
12
15
|
const str = {type:'string',maxLength:128};
|
|
13
16
|
export async function allowed(audience, caller, env) {
|
|
14
17
|
if (audience === 'public') return true;
|
|
15
|
-
if (!caller.id) return false;
|
|
18
|
+
if (!caller.id || caller.studio) return false;
|
|
16
19
|
if (audience === 'signed-in') return true;
|
|
17
20
|
if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
|
|
18
21
|
if (audience === 'owner') return caller.owner === true;
|
|
@@ -70,12 +73,31 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
70
73
|
const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
|
|
71
74
|
return Object.freeze({
|
|
72
75
|
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
73
|
-
|
|
76
|
+
studios:studiosClient(env,cat,definition,{origin}),
|
|
77
|
+
bookings:Object.freeze({...bookingHolds(env,caller,cat),availability:args=>bookingAvailability(env,cat,caller,args)}),
|
|
74
78
|
events:Object.freeze({publish:args=>import('./business-events.mjs').then(m=>m.publishBusinessEvent(env,caller,definition,namespace,args))}),
|
|
75
79
|
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
76
80
|
shop:()=>office('/_studio/api/shop'),
|
|
77
81
|
database:toolDatabase(env.DB,namespace),
|
|
78
82
|
fetch:globalThis.fetch.bind(globalThis),
|
|
83
|
+
async fetchSelf(path,init={}) {
|
|
84
|
+
const url=new URL(path,origin);
|
|
85
|
+
if(url.origin!==new URL(origin).origin || url.username || url.password)throw new Error('Same origin required');
|
|
86
|
+
const headers=new Headers(init.headers);
|
|
87
|
+
if(headers.has('authorization')||headers.has('cookie'))throw new Error('Explicit credentials refused');
|
|
88
|
+
headers.set('origin',new URL(origin).origin);
|
|
89
|
+
const request=withToolIdentity(new Request(url,{...init,headers,redirect:'error'}),caller);
|
|
90
|
+
const pending=[];
|
|
91
|
+
const response=await (await import('./custom-routes.mjs')).customRoute(request,env,{waitUntil:promise=>pending.push(promise)});
|
|
92
|
+
await Promise.all(pending);
|
|
93
|
+
return response??new Response('Not found',{status:404});
|
|
94
|
+
},
|
|
95
|
+
async rateLimit(name,key=callerLimitKey(caller)) {
|
|
96
|
+
const rule=cat.studio?.mcp?.rateLimits?.[name];
|
|
97
|
+
if(!rule?.tools?.includes(definition.name)||!env[rule.binding]?.limit)throw new Error('Rate limit not declared for this tool');
|
|
98
|
+
if(typeof key!=='string'||!key.length||key.length>256)throw new Error('Rate limit key required (1–256 characters)');
|
|
99
|
+
return env[rule.binding].limit({key:JSON.stringify([name,key])});
|
|
100
|
+
},
|
|
79
101
|
// A named secret may authenticate an HTTPS request; its value is never returned.
|
|
80
102
|
async fetchWithSecret(name,url,init={}) {
|
|
81
103
|
const rule=cat.studio?.mcp?.secrets?.[name];
|
|
@@ -178,6 +200,7 @@ export function builtins(env,cat,origin) {
|
|
|
178
200
|
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
179
201
|
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
180
202
|
}),
|
|
203
|
+
tool('studio_availability','List available booking slots and options before studio_book. Returns schema.org Offers with exact booking arguments; availability is a snapshot, and booking rechecks capacity.','public',object({app:str,collection:str,after:str,quantity:{type:'integer',minimum:1},option:str,resource:str,addons:{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}},['app','collection']),async(args,ctx)=>ctx.bookings.availability(args)),
|
|
181
204
|
tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
|
|
182
205
|
const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
|
|
183
206
|
if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
|