@homie-rocks/studio 0.51.7 → 0.52.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 +54 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/bin/homie-studio.mjs +9 -4
- package/docs/toolkit-place-patterns.md +68 -0
- package/functions/FUNCTIONS.md +27 -0
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +123 -0
- package/guides/app/SKILL.md +11 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-needs.mjs +41 -6
- package/lib/app-ready.mjs +31 -0
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +13 -10
- package/lib/cloudflare.mjs +2 -2
- package/lib/connector-token.mjs +30 -0
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +6 -1
- package/lib/functions-build.mjs +3 -0
- package/lib/mcp-tools.mjs +2 -2
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +4 -4
- package/lib/shop-mcp.mjs +82 -14
- package/lib/stripe-connect.mjs +9 -4
- package/lib/template-history.json +84 -0
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +6 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +61 -17
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/booking-holds.mjs +87 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/connector-token.mjs +11 -0
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +24 -6
- package/worker/join-area.mjs +35 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +17 -9
- package/worker/pages.mjs +10 -7
- package/worker/records-page.mjs +5 -4
- package/worker/records-prefs.mjs +24 -0
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/worker/app-records.mjs
CHANGED
|
@@ -103,11 +103,15 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
103
103
|
for(const r of rows) {
|
|
104
104
|
const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
|
|
105
105
|
const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===r.id);
|
|
106
|
+
if(Object.values(meta.records.collections).some(c=>c.booking?.slots===collection)) {
|
|
107
|
+
const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state IN ('held','confirmed')").bind(recordApp,collection,r.id).first();
|
|
108
|
+
record.booking={full:count.n>=record.data.capacity,available:Math.max(0,record.data.capacity-count.n),synced:true};
|
|
109
|
+
}
|
|
106
110
|
if(link) {
|
|
107
111
|
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
|
|
108
112
|
const observed=saved?JSON.parse(saved.value):null;
|
|
109
113
|
const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
|
|
110
|
-
const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state
|
|
114
|
+
const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state IN ('held','confirmed')").bind(recordApp,collection,r.id).first();
|
|
111
115
|
record.booking={full:!status.active||status.count>=(link.limit??link.capacity)||count.n>=link.capacity,available:status.active?Math.max(0,Math.min(link.capacity-count.n,(link.limit??link.capacity)-status.count)):0,synced:link.capacity===record.data.capacity};
|
|
112
116
|
}
|
|
113
117
|
records.push(record);
|
package/worker/app-shell.mjs
CHANGED
|
@@ -4,26 +4,70 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
4
4
|
if (!boot || boot.kind !== 'app') return;
|
|
5
5
|
var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
6
|
var params = new URLSearchParams(location.search);
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
7
|
+
// Share short-lived reads across same-origin screens with the exact same role/access.
|
|
8
|
+
// No records are persisted. Writes always reach the Worker and invalidate all views.
|
|
9
|
+
var recent = new Map(), failures = new Map(), pending = new Map();
|
|
10
|
+
var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
|
|
11
|
+
function accept(m) { if (m?.invalidate) { recent.clear(); failures.clear(); } else if (m?.path && m?.entry) recent.set(m.path, m.entry); }
|
|
12
|
+
if (channel) channel.onmessage = function(e) { accept(e.data); };
|
|
13
|
+
function share(m) { accept(m); if (channel) channel.postMessage(m); }
|
|
14
|
+
function locked(run) { return typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('homie-app-records-v1', run) : run(); }
|
|
15
|
+
async function request(path, m) {
|
|
16
|
+
var read = m.type!=='homie-app-tool' && m.method === 'GET';
|
|
17
|
+
if (read && pending.has(path)) return pending.get(path);
|
|
18
|
+
var job = locked(async function() {
|
|
19
|
+
var cached = recent.get(path);
|
|
20
|
+
if (read && cached && cached.until > Date.now()) return cached.result;
|
|
21
|
+
var result;
|
|
17
22
|
try {
|
|
18
|
-
var
|
|
19
|
-
['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
20
|
-
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
21
|
-
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
22
|
-
var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: m.method === 'GET' ? undefined : JSON.stringify({ data: m.data, version: m.version }) });
|
|
23
|
+
var res = await fetch(path, { method: m.type==='homie-app-tool'?'POST':m.method, headers: { 'content-type': 'application/json' }, body: m.type==='homie-app-tool'?JSON.stringify({name:m.name,input:m.input}):read ? undefined : JSON.stringify({ data: m.data, version: m.version }), signal: AbortSignal.timeout(8000) });
|
|
23
24
|
result = await res.json();
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
if (res.status === 429 || res.status >= 500) throw new Error('reconnect');
|
|
26
|
+
failures.delete(path);
|
|
27
|
+
if (read && result.ok) share({path: path, entry: {result: result, until: Date.now() + 1500}});
|
|
28
|
+
if (!read && result.ok) share({invalidate: true});
|
|
29
|
+
} catch (_) {
|
|
30
|
+
result = {ok: false, error: 'Reconnecting. Your saved updates will appear shortly.'};
|
|
31
|
+
if (read) { var count = (failures.get(path) || 0) + 1; failures.set(path, count); share({path: path, entry: {result: result, until: Date.now() + Math.min(30000, 1000 * Math.pow(2, count - 1))}}); }
|
|
32
|
+
}
|
|
33
|
+
return result;
|
|
34
|
+
});
|
|
35
|
+
if (read) { pending.set(path, job); job.finally(function() { pending.delete(path); }); }
|
|
36
|
+
return job;
|
|
37
|
+
}
|
|
38
|
+
var live, collections=new Set(), presence=0, clock=null;
|
|
39
|
+
function query() {
|
|
40
|
+
var q=new URLSearchParams();
|
|
41
|
+
['role','surface','access','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
|
|
42
|
+
if(boot.screen&&!q.has('role'))q.set('surface','wall');
|
|
43
|
+
return q;
|
|
44
|
+
}
|
|
45
|
+
function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
|
|
46
|
+
function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
|
|
47
|
+
function connect(){
|
|
48
|
+
live?.close();
|
|
49
|
+
var q=query();collections.forEach(function(c){q.append('collection',c);});
|
|
50
|
+
live=new EventSource((boot.site||'')+'/'+boot.game+'/api/app/live?'+q);
|
|
51
|
+
live.onmessage=function(e){var value=JSON.parse(e.data);if(presence!==value.presence){presence=value.presence;sendContext();}if(value.collections?.length){share({invalidate:true});Promise.allSettled([...pending.values()]).then(function(){share({invalidate:true});frame?.contentWindow?.postMessage({type:'homie-app-changed',collections:value.collections},'*');});}};
|
|
52
|
+
}
|
|
53
|
+
window.homieApp=Object.freeze({get context(){return context();},setTestClock:function(value){if(!boot.test)throw new Error('Local proof session required');if(value!==null&&!Number.isFinite(value))throw new Error('Invalid clock');clock=value;sendContext();}});
|
|
54
|
+
addEventListener('pagehide',function(){live?.close();});
|
|
55
|
+
addEventListener('message', async function (e) {
|
|
56
|
+
var m = e.data;
|
|
57
|
+
if(frame&&e.source===frame.contentWindow&&m?.type==='homie-app-context'){sendContext();return;}
|
|
58
|
+
if(frame&&e.source===frame.contentWindow&&m?.type==='homie-app-subscribe'){
|
|
59
|
+
if(!Array.isArray(m.collections)||m.collections.some(c=>!/^[a-z][a-z0-9-]{0,39}$/.test(c)))return;
|
|
60
|
+
m.collections.forEach(c=>collections.add(c));connect();return;
|
|
26
61
|
}
|
|
62
|
+
if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
|
|
63
|
+
if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
64
|
+
if (m.type==='homie-app' && (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record)))) return;
|
|
65
|
+
var q = new URLSearchParams();
|
|
66
|
+
['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
67
|
+
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
68
|
+
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
69
|
+
if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
|
|
70
|
+
var result = await request(path, m);
|
|
27
71
|
e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
|
|
28
72
|
});
|
|
29
73
|
})();`;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import {appAccess} from './app-records.mjs';
|
|
2
|
+
import {localSession} from './local-test.mjs';
|
|
3
|
+
import {settingsOf,launchOf,accessOf} from './office.mjs';
|
|
4
|
+
export const APP_LIVE_MIGRATION_FILE='0027_app_live.sql';
|
|
5
|
+
export const APP_LIVE_MIGRATION=`
|
|
6
|
+
CREATE TABLE app_record_revisions(app TEXT NOT NULL,collection TEXT NOT NULL,revision INTEGER NOT NULL,PRIMARY KEY(app,collection));
|
|
7
|
+
${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `CREATE TRIGGER app_records_live_${op.toLowerCase()} AFTER ${op} ON app_records BEGIN
|
|
8
|
+
INSERT INTO app_record_revisions VALUES(${row}.app,${row}.collection,1) ON CONFLICT(app,collection) DO UPDATE SET revision=revision+1;
|
|
9
|
+
END;`;}).join('\n')}
|
|
10
|
+
`;
|
|
11
|
+
const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
|
|
12
|
+
export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
|
|
13
|
+
export async function pushAppRecords(env,cat){
|
|
14
|
+
if(!env.LOBBY||!env.DB)return;
|
|
15
|
+
await Promise.all((cat.games??[]).filter(g=>g.kind==='app').map(meta=>appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})}).catch(error=>console.error('App notification wake failed',error))));
|
|
16
|
+
}
|
|
17
|
+
/** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
|
|
18
|
+
export class AppRecordStreams {
|
|
19
|
+
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
20
|
+
dispose(){for(const s of [...this.streams])this.close(s);}
|
|
21
|
+
close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);try{s.controller.close();}catch{}this.presence();}
|
|
22
|
+
count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
|
|
23
|
+
send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
|
|
24
|
+
presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
|
|
25
|
+
async authorized(s){
|
|
26
|
+
const access=await appAccess(s.request,this.env,s.meta,s.url);
|
|
27
|
+
if(!access.ok||s.collections.some(c=>!access.can.includes('read:'+c)))return false;
|
|
28
|
+
const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
|
|
29
|
+
return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
|
|
30
|
+
}
|
|
31
|
+
async revisions(s){return (await this.env.DB.prepare('SELECT collection,revision FROM app_record_revisions WHERE app=?1').bind(s.app).all()).results;}
|
|
32
|
+
async open({meta,url,headers,collections=[]}){
|
|
33
|
+
if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
|
|
34
|
+
const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
|
|
35
|
+
if(!await this.authorized(s))return new Response('role cannot read collections',{status:403});
|
|
36
|
+
const session=await localSession(s.request,this.env);
|
|
37
|
+
s.app=session?.app===meta.id&&session.scope?`${meta.id}:check:${session.scope}`:meta.id;
|
|
38
|
+
s.room=s.url.searchParams.get('room')??'main';
|
|
39
|
+
for(const row of await this.revisions(s))s.seen.set(row.collection,row.revision);
|
|
40
|
+
const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
|
|
41
|
+
this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
|
|
42
|
+
for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
|
|
43
|
+
// Recovery after a missed wake; this is not the screen's refresh clock.
|
|
44
|
+
s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
|
|
45
|
+
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
46
|
+
}
|
|
47
|
+
async pump(s){
|
|
48
|
+
if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
|
|
49
|
+
try{
|
|
50
|
+
if(!await this.authorized(s)){this.close(s);return;}
|
|
51
|
+
const changed=[];
|
|
52
|
+
for(const row of await this.revisions(s)){if(s.collections.includes(row.collection)&&s.seen.get(row.collection)!==row.revision)changed.push(row.collection);s.seen.set(row.collection,row.revision);}
|
|
53
|
+
this.send(s,{collections:changed,presence:this.count(s)});
|
|
54
|
+
}catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
|
|
55
|
+
}
|
|
56
|
+
async wake(meta){for(const s of this.streams)if(meta)s.meta=meta;await Promise.all([...this.streams].map(s=>this.pump(s)));return new Response('ok');}
|
|
57
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import {CfWorkerJsonSchemaValidator} from '@modelcontextprotocol/server/validators/cf-worker';
|
|
2
|
+
import {allowed,services} from './mcp-tools.mjs';
|
|
3
|
+
import {appAccess} from './app-records.mjs';
|
|
4
|
+
import {localSession} from './local-test.mjs';
|
|
5
|
+
import {players} from './players.mjs';
|
|
6
|
+
import {isOwner,sameOrigin} from './office.mjs';
|
|
7
|
+
import {audit,callsPerMinute,rateLimit} from './mcp-store.mjs';
|
|
8
|
+
const validator=new CfWorkerJsonSchemaValidator();
|
|
9
|
+
export async function invokeAppTool(tool,input,context){
|
|
10
|
+
if(!validator.getValidator(tool.inputSchema)(input).valid)throw Object.assign(new Error('Input does not match the tool schema'),{status:400});
|
|
11
|
+
return tool.handler(input,context);
|
|
12
|
+
}
|
|
13
|
+
export async function appCaller(request,env,meta,url){
|
|
14
|
+
const access=await appAccess(request,env,meta,url);
|
|
15
|
+
if(!access.ok)throw Object.assign(new Error(access.error),{status:access.status});
|
|
16
|
+
const session=await localSession(request,env);
|
|
17
|
+
if(session?.app===meta.id)return {id:`local-test-${session.app}-${session.role}`,owner:session.role==='owner',client:null,localTest:true,roles:{[meta.id]:[session.role]},testApp:meta.id,testScope:session.scope};
|
|
18
|
+
const person=await players.of(request,env);
|
|
19
|
+
const owner=await isOwner(request,env);
|
|
20
|
+
const roles={};
|
|
21
|
+
if(person&&!person.guest){for(const row of (await env.DB.prepare('SELECT app,role FROM app_roles WHERE player=?1').bind(person.id).all()).results)(roles[row.app]??=[]).push(row.role);}
|
|
22
|
+
return {id:owner?(person?.id??'browser-owner'):person&&!person.guest?person.id:null,owner,client:null,roles};
|
|
23
|
+
}
|
|
24
|
+
/** Same-origin shell transport; body fields never supply an identity or a role. */
|
|
25
|
+
export async function appToolsRoute(request,env,meta,url,cat,definitions){
|
|
26
|
+
const fail=(status,error)=>Response.json({ok:false,error},{status});
|
|
27
|
+
if(request.method!=='POST')return fail(405,'POST required');
|
|
28
|
+
if(!sameOrigin(request,url))return fail(403,'same origin required');
|
|
29
|
+
let caller,tool,outcome='error';
|
|
30
|
+
try{
|
|
31
|
+
caller=await appCaller(request,env,meta,url);
|
|
32
|
+
const reader=request.body?.getReader();let size=0,text='';const decoder=new TextDecoder();
|
|
33
|
+
if(reader)for(;;){const {done,value}=await reader.read();if(done)break;size+=value.byteLength;if(size>12288){await reader.cancel();return fail(413,'body too large');}text+=decoder.decode(value,{stream:true});}
|
|
34
|
+
const body=JSON.parse(text+decoder.decode());
|
|
35
|
+
tool=definitions.find(t=>t.name===body.name&&(!t.kind||t.kind==='tool'));
|
|
36
|
+
if(!tool||!await allowed(tool.audience,caller,env)){outcome='denied';return fail(403,'Permission denied or unknown tool');}
|
|
37
|
+
if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
|
|
38
|
+
if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
|
|
39
|
+
const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
|
|
40
|
+
const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
|
|
41
|
+
outcome='ok';return Response.json({ok:true,data});
|
|
42
|
+
}catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
|
|
43
|
+
finally{if(caller)await audit(env,caller,tool?.name??'unknown-tool',outcome);}
|
|
44
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import {BOOKINGS_MIGRATION} from './booking-store.mjs';
|
|
2
|
+
import {linkConfig,hasStripeKey} from './shop-links.mjs';
|
|
3
|
+
export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
|
|
4
|
+
export const BOOKING_HOLDS_MIGRATION=`
|
|
5
|
+
ALTER TABLE shop_bookings ADD COLUMN paid_at INTEGER;
|
|
6
|
+
ALTER TABLE shop_bookings ADD COLUMN actor TEXT;
|
|
7
|
+
ALTER TABLE shop_bookings ADD COLUMN token_hash TEXT;
|
|
8
|
+
ALTER TABLE shop_bookings ADD COLUMN hold_until INTEGER;
|
|
9
|
+
DROP TRIGGER booking_capacity_move;
|
|
10
|
+
CREATE TRIGGER booking_capacity_move BEFORE UPDATE OF slot,quantity,state ON shop_bookings
|
|
11
|
+
WHEN NEW.state IN ('held','confirmed') BEGIN
|
|
12
|
+
SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
|
|
13
|
+
SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
|
|
14
|
+
AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
|
|
15
|
+
(SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND id!=NEW.id AND state IN ('held','confirmed')));
|
|
16
|
+
END;
|
|
17
|
+
CREATE TRIGGER booking_hold_claim BEFORE UPDATE OF order_id ON shop_bookings BEGIN
|
|
18
|
+
SELECT RAISE(ABORT,'booking hold changed or expired') WHERE NEW.order_id IS NULL OR OLD.order_id!='hold:'||OLD.id OR OLD.state NOT IN ('held','confirmed') OR (OLD.state='held' AND OLD.hold_until<=NEW.updated_at);
|
|
19
|
+
END;
|
|
20
|
+
DROP TRIGGER booking_order_state;
|
|
21
|
+
CREATE TRIGGER booking_order_state AFTER UPDATE OF status,paid_at ON shop_orders
|
|
22
|
+
WHEN NEW.status!=OLD.status OR (OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL) BEGIN
|
|
23
|
+
UPDATE shop_bookings SET state=CASE WHEN NEW.status='paid' THEN 'confirmed' ELSE 'released' END,
|
|
24
|
+
paid_at=COALESCE(NEW.paid_at,paid_at),version=version+1,updated_at=NEW.updated_at
|
|
25
|
+
WHERE order_id=NEW.id AND ((state IN ('pending','held','confirmed') AND NEW.status='paid')
|
|
26
|
+
OR (state IN ('pending','held') AND NEW.status IN ('expired','failed'))
|
|
27
|
+
OR (state IN ('pending','held','confirmed') AND NEW.status IN ('refunded','lost')));
|
|
28
|
+
END;
|
|
29
|
+
${['insert','update'].map(op=>{
|
|
30
|
+
const trigger=BOOKINGS_MIGRATION.match(new RegExp('CREATE TRIGGER booking_record_'+op+'[\\s\\S]*?END;'))[0];
|
|
31
|
+
return 'DROP TRIGGER booking_record_'+op+';\n'+trigger.replace("'state',NEW.state)","'state',NEW.state,'paid',json(CASE WHEN NEW.paid_at IS NULL THEN 'false' ELSE 'true' END))");
|
|
32
|
+
}).join('\n')}
|
|
33
|
+
UPDATE shop_bookings SET paid_at=(SELECT paid_at FROM shop_orders WHERE id=order_id),version=version+1 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=order_id AND paid_at IS NOT NULL);
|
|
34
|
+
`;
|
|
35
|
+
const refusal=(message,status=400)=>Object.assign(new Error(message),{name:'StudioOperationError',status});
|
|
36
|
+
const valid=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(v);
|
|
37
|
+
const hash=async token=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token))),b=>b.toString(16).padStart(2,'0')).join('');
|
|
38
|
+
export async function ownedHold(env,caller,{id,token}){
|
|
39
|
+
if(!valid(id))throw refusal('Invalid booking id');
|
|
40
|
+
const row=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(id).first();
|
|
41
|
+
if(!row||!(caller.owner||row.actor&&row.actor===caller.id||row.token_hash&&typeof token==='string'&&await hash(token)===row.token_hash))throw refusal('Booking owner required',403);
|
|
42
|
+
if(caller.testScope&&!row.app.endsWith(':check:'+caller.testScope))throw refusal('Booking owner required',403);
|
|
43
|
+
return row;
|
|
44
|
+
}
|
|
45
|
+
/** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
|
|
46
|
+
export function bookingHolds(env,caller,cat){
|
|
47
|
+
const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null});
|
|
48
|
+
const change=async(args,state)=>{
|
|
49
|
+
const row=await ownedHold(env,caller,args);
|
|
50
|
+
if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release');
|
|
51
|
+
if(!Number.isSafeInteger(args.version)||args.version<1)throw refusal('Booking version required');
|
|
52
|
+
const changed=await env.DB.prepare(`UPDATE shop_bookings SET state=?2,version=version+1,updated_at=?3 WHERE id=?1 AND version=?4 AND state IN ('held','confirmed') AND (?2='released' OR hold_until>?3) RETURNING *`).bind(row.id,state,Date.now(),args.version).first();
|
|
53
|
+
if(!changed)throw refusal('Booking changed or expired; reload',409);return result(changed);
|
|
54
|
+
};
|
|
55
|
+
return Object.freeze({
|
|
56
|
+
async hold({app,collection,slot,quantity=1,id,token,expiresAt}){
|
|
57
|
+
const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
|
|
58
|
+
if(!spec||!valid(id)||!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1)throw refusal('Choose a declared slot, positive quantity and stable booking id');
|
|
59
|
+
if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry');
|
|
60
|
+
if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token');
|
|
61
|
+
if(!caller.id&&!token)throw refusal('Booking owner or private token required');
|
|
62
|
+
if(!hasStripeKey(env)&&linkConfig(env)?.slots?.some(l=>l.app===app&&l.slots===spec.slots&&l.slot===slot))throw refusal('Keyless Stripe inventory cannot share local holds; configure session checkout first');
|
|
63
|
+
const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
|
|
64
|
+
id=await hash(JSON.stringify([scope,collection,id]));
|
|
65
|
+
const secret=token?await hash(token):null;
|
|
66
|
+
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)
|
|
67
|
+
SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10 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).run();}catch(error){if(/booking capacity unavailable/.test(error.message))throw refusal('That slot is full. No place has been taken.',409);throw error;}
|
|
68
|
+
const row=await ownedHold(env,caller,{id,token});
|
|
69
|
+
if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity)throw refusal('Booking id reused with different request',409);
|
|
70
|
+
return result(row);
|
|
71
|
+
},
|
|
72
|
+
async get(args){return result(await ownedHold(env,caller,args));},
|
|
73
|
+
confirm:args=>change(args,'confirmed'),release:args=>change(args,'released'),
|
|
74
|
+
async releaseExpired({app,collection,now=Date.now()}){
|
|
75
|
+
if(!caller.owner)throw refusal('Owner or scheduled function required');
|
|
76
|
+
if(!Number.isSafeInteger(now))throw refusal('Invalid release time');
|
|
77
|
+
const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
|
|
78
|
+
const r=await env.DB.prepare("UPDATE shop_bookings SET state='released',version=version+1,updated_at=?3 WHERE app=?1 AND collection=?2 AND order_id='hold:'||id AND state='held' AND hold_until<=?3").bind(scope,collection,now).run();return {released:r.meta.changes};
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
/** Used in the same transaction as the order reservation. A second checkout cannot claim the hold. */
|
|
83
|
+
export function claimHoldStatement(env,{hold,app,collection,slots,slot,quantity,order,now=Date.now()}){
|
|
84
|
+
return env.DB.prepare(`UPDATE shop_bookings SET order_id=CASE WHEN app=?4 AND collection=?5 AND slots=?6 AND slot=?7 AND quantity=?8 THEN ?2 ELSE NULL END,version=version+1,updated_at=?3
|
|
85
|
+
WHERE id=?1
|
|
86
|
+
AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?2)`).bind(hold,order,now,app,collection,slots,slot,quantity);
|
|
87
|
+
}
|
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.52.0","sha256":"f2d43374183c3a1d807c67754b0b9e868b18d5f6b137e0fd5f224fd6e5866e17"});
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import {canonicalJson} from './canonical-json.mjs';
|
|
2
|
+
export const BUSINESS_EVENTS_MIGRATION_FILE='0028_business_events.sql';
|
|
3
|
+
export const BUSINESS_EVENTS_MIGRATION=`
|
|
4
|
+
ALTER TABLE studio_events ADD COLUMN scope TEXT;
|
|
5
|
+
CREATE TABLE event_publications(id TEXT PRIMARY KEY,digest TEXT NOT NULL);
|
|
6
|
+
${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `
|
|
7
|
+
DROP TRIGGER IF EXISTS app_records_function_${op.toLowerCase()};
|
|
8
|
+
CREATE TRIGGER app_records_function_${op.toLowerCase()} AFTER ${op} ON app_records
|
|
9
|
+
WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='record.changed') BEGIN
|
|
10
|
+
INSERT INTO studio_events(id,type,data,at) VALUES(lower(hex(randomblob(16))),'record.changed',
|
|
11
|
+
json_object('app',${row}.app,'collection',${row}.collection,'record',${row}.id,'version',${row}.version,'operation','${op.toLowerCase()}',
|
|
12
|
+
'data',json(${row}.value),'previous',${op==='INSERT'?'NULL':'json(OLD.value)'}),${row}.updated_at);
|
|
13
|
+
END;`;}).join('\n')}
|
|
14
|
+
`;
|
|
15
|
+
/** Durable idempotency survives feed retention/pruning; no sleep or read-back races. */
|
|
16
|
+
export async function publishBusinessEvent(env,caller,definition,namespace,{feed,id,data}){
|
|
17
|
+
const declaration=JSON.parse(env.STUDIO_EVENT_FEEDS??'{}')[feed];
|
|
18
|
+
if(!definition.publish?.includes(feed)||!declaration?.schema)throw new Error('Business feed must be declared with a schema and publish capability');
|
|
19
|
+
if(typeof id!=='string'||!id.length||id.length>128)throw new Error('Event id must contain 1–128 characters');
|
|
20
|
+
const {CfWorkerJsonSchemaValidator}=await import('@modelcontextprotocol/server/validators/cf-worker');
|
|
21
|
+
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>8192)throw new Error('Event exceeds 8192 characters');
|
|
23
|
+
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
|
+
await env.DB.batch([
|
|
25
|
+
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),
|
|
26
|
+
env.DB.prepare('INSERT OR IGNORE INTO event_publications(id,digest) VALUES(?1,?2)').bind(key,digest)
|
|
27
|
+
]);
|
|
28
|
+
const saved=await env.DB.prepare('SELECT digest FROM event_publications WHERE id=?1').bind(key).first();
|
|
29
|
+
if(saved.digest!==digest)throw new Error('Event id already published with different data');
|
|
30
|
+
return {id:key};
|
|
31
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import {oauthStorage} from './mcp-store.mjs';
|
|
2
|
+
/** A hashed bearer, bound to this origin and exact tool names; re-read on every dispatch. */
|
|
3
|
+
export async function tokenCaller(request,env) {
|
|
4
|
+
const token=/^Bearer (homie_owner_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
|
|
5
|
+
const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));
|
|
6
|
+
const id=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('');
|
|
7
|
+
const row=await oauthStorage(env.DB).get('connector:'+id,'json');
|
|
8
|
+
if(!row||row.origin!==new URL(request.url).origin||row.expires<=Date.now()/1000)return null;
|
|
9
|
+
return {id:'connector:'+id,owner:true,client:'owner-connector',connection:'connector:'+id,tools:row.tools};
|
|
10
|
+
}
|
|
11
|
+
export function toolInScope(caller,name){return !caller.tools||caller.tools.includes(name);}
|
|
@@ -28,7 +28,7 @@ export async function readableFeed(env,cat,caller,name) {
|
|
|
28
28
|
export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER,caller}={}) {
|
|
29
29
|
const feed=eventFeeds(env)[name];if(!feed)return [];
|
|
30
30
|
const scopedApp=env.HOMIE_LOCAL_TEST_SECRET&&caller?.localTest&&caller.testScope&&feed.event==='record.changed'?`${feed.app}:check:${caller.testScope}`:null;
|
|
31
|
-
const rows=(await env.DB.prepare("SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 AND (?4 IS NULL OR json_extract(data,'$.app')=?4) ORDER BY seq LIMIT 100").bind(feed.event,after,through,scopedApp).all()).results;
|
|
31
|
+
const rows=(await env.DB.prepare("SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 AND scope IS ?5 AND (?4 IS NULL OR json_extract(data,'$.app')=?4) ORDER BY seq LIMIT 100").bind(feed.event,after,through,scopedApp,feed.event==='record.changed'?null:caller?.testScope??null).all()).results;
|
|
32
32
|
const events=rows.map(r=>({...r,data:JSON.parse(r.data)}));
|
|
33
33
|
if(env.HOMIE_LOCAL_TEST_SECRET && caller?.localTest && caller.testScope && feed.event==='record.changed') {
|
|
34
34
|
const app=`${feed.app}:check:${caller.testScope}`;
|
package/worker/functions.mjs
CHANGED
|
@@ -43,6 +43,10 @@ export async function pruneEvents(env) {
|
|
|
43
43
|
/** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
|
|
44
44
|
export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000, webhookOptions = {}} = {}) {
|
|
45
45
|
if(!env.DB)return;
|
|
46
|
+
if(env.LOBBY){
|
|
47
|
+
if(typeof cat==='function')cat=await cat();
|
|
48
|
+
await (await import('./app-streams.mjs')).pushAppRecords(env,cat);
|
|
49
|
+
}
|
|
46
50
|
definitions = await syncFunctions(env,definitions,now());
|
|
47
51
|
if(!definitions.length&&!env.STUDIO_EVENT_FEEDS)return;
|
|
48
52
|
if(typeof cat==='function')cat=await cat();
|
|
@@ -95,6 +99,7 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
95
99
|
}
|
|
96
100
|
}
|
|
97
101
|
} while(more);
|
|
102
|
+
await (await import('./app-streams.mjs')).pushAppRecords(env,cat);
|
|
98
103
|
await notifications;
|
|
99
104
|
await notify();
|
|
100
105
|
await pruneEvents(env);
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {appGraphicsFallback} from './app-graphics.mjs';
|
|
2
|
+
import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
|
|
3
|
+
import {loadShopLinks} from './shop-links.mjs';
|
|
1
4
|
import { directoryUrl } from './directory-choice.mjs';
|
|
2
5
|
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
3
6
|
import {recordsPage} from './records-page.mjs';
|
|
@@ -506,6 +509,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
506
509
|
const params = paramsFrom(url.searchParams, meta);
|
|
507
510
|
if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
|
|
508
511
|
const cfg = {
|
|
512
|
+
...(meta.kind === 'app' ? {kind: 'app'} : {}),
|
|
509
513
|
v: 1,
|
|
510
514
|
url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
|
|
511
515
|
room,
|
|
@@ -534,8 +538,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
534
538
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
535
539
|
};
|
|
536
540
|
let html = await res.text();
|
|
537
|
-
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',
|
|
538
|
-
const head = (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
541
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
|
|
542
|
+
const head = `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
539
543
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
540
544
|
return new Response(html, {
|
|
541
545
|
headers: {
|
|
@@ -618,6 +622,7 @@ async function removeReferralLeftovers(env) {
|
|
|
618
622
|
|
|
619
623
|
export default {
|
|
620
624
|
async scheduled(event, env, ctx) {
|
|
625
|
+
env=await loadShopLinks(env);
|
|
621
626
|
const maintenance=(async () => {
|
|
622
627
|
await removeReferralLeftovers(env);
|
|
623
628
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
@@ -629,6 +634,7 @@ export default {
|
|
|
629
634
|
await maintenance;
|
|
630
635
|
},
|
|
631
636
|
async fetch(request, env, ctx) {
|
|
637
|
+
env=await loadShopLinks(env,new URL(request.url).origin);
|
|
632
638
|
await removeReferralLeftovers(env);
|
|
633
639
|
const url = new URL(request.url);
|
|
634
640
|
await (await import('./functions.mjs')).syncFunctions(env);
|
|
@@ -944,6 +950,12 @@ async function route(request, env, ctx) {
|
|
|
944
950
|
const max = seatsFor(meta, settings);
|
|
945
951
|
if (meta.kind === 'app') {
|
|
946
952
|
if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
|
|
953
|
+
if(sub==='api/app/live') {
|
|
954
|
+
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
955
|
+
const {appHub}=await import('./app-streams.mjs');
|
|
956
|
+
return appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})});
|
|
957
|
+
}
|
|
958
|
+
if(sub==='api/app/tools')return (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools());
|
|
947
959
|
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
948
960
|
if (api) return api;
|
|
949
961
|
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
@@ -953,7 +965,7 @@ async function route(request, env, ctx) {
|
|
|
953
965
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
954
966
|
return json({ ok: false, error: a.error }, a.status);
|
|
955
967
|
}
|
|
956
|
-
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game));
|
|
968
|
+
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)));
|
|
957
969
|
}
|
|
958
970
|
}
|
|
959
971
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -1098,11 +1110,11 @@ async function route(request, env, ctx) {
|
|
|
1098
1110
|
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
1099
1111
|
let shopQr = null;
|
|
1100
1112
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
1101
|
-
return playPage(cat, meta, { origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
1113
|
+
return playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
|
|
1102
1114
|
}
|
|
1103
1115
|
await countVisit(request, env, ctx, game, 'play');
|
|
1104
1116
|
shareDaily(cat, url, ctx);
|
|
1105
|
-
const page=playPage(cat, meta, { origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1117
|
+
const page=playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
|
|
1106
1118
|
for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
|
|
1107
1119
|
}
|
|
1108
1120
|
if (sub === 'watch') {
|
|
@@ -1120,7 +1132,7 @@ async function route(request, env, ctx) {
|
|
|
1120
1132
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
1121
1133
|
await countVisit(request, env, ctx, game, 'watch');
|
|
1122
1134
|
shareDaily(cat, url, ctx);
|
|
1123
|
-
return watchPage(cat, meta, { origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
1135
|
+
return watchPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
1124
1136
|
}
|
|
1125
1137
|
if (sub === 'api/watch') {
|
|
1126
1138
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -2601,6 +2613,12 @@ export class Lobby {
|
|
|
2601
2613
|
|
|
2602
2614
|
/** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
|
|
2603
2615
|
async fetch(request) {
|
|
2616
|
+
if(new URL(request.url).pathname==='/__app_records') {
|
|
2617
|
+
const {AppRecordStreams}=await import('./app-streams.mjs');
|
|
2618
|
+
this.appStreams??=new AppRecordStreams(this.env,this.ctx);
|
|
2619
|
+
const input=await request.json();
|
|
2620
|
+
return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
|
|
2621
|
+
}
|
|
2604
2622
|
if(new URL(request.url).pathname==='/__studio_events') {
|
|
2605
2623
|
const {EventStreams}=await import('./event-resources.mjs');
|
|
2606
2624
|
this.eventStreams??=new EventStreams(this.env,this.ctx);
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** The shell owns these measurements; sandboxed views receive only geometry. */
|
|
2
|
+
export function joinAreaShell() {
|
|
3
|
+
const frame=document.querySelector('iframe.game');
|
|
4
|
+
const update=()=>{
|
|
5
|
+
const card=document.querySelector('[data-join]');
|
|
6
|
+
const r=card?.getBoundingClientRect();
|
|
7
|
+
const visible=!!(r?.width&&r.height&&getComputedStyle(card).visibility!=='hidden'&&getComputedStyle(card).display!=='none');
|
|
8
|
+
const corner=card?.className??'';
|
|
9
|
+
const box=visible?{x:r.x,y:r.y,width:r.width,height:r.height,top:corner.includes('top')?r.bottom:0,bottom:corner.includes('top')?0:innerHeight-r.top,left:corner.includes('left')?r.right:0,right:corner.includes('left')?0:innerWidth-r.left}:{};
|
|
10
|
+
document.body.classList.toggle('homie-join-visible',visible);
|
|
11
|
+
for(const k of ['top','bottom','left','right'])document.documentElement.style.setProperty('--homie-join-inset-'+k,(box[k]??0)+'px');
|
|
12
|
+
for(const k of ['x','y','width','height'])document.documentElement.style.setProperty('--homie-join-'+k,(box[k]??0)+'px');
|
|
13
|
+
frame?.contentWindow?.postMessage({type:'homie-join-area',visible,box},'*');
|
|
14
|
+
};
|
|
15
|
+
new ResizeObserver(update).observe(document.body);
|
|
16
|
+
const card=document.querySelector('[data-join]');if(card)new ResizeObserver(update).observe(card);
|
|
17
|
+
new MutationObserver(update).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','class']});
|
|
18
|
+
frame?.addEventListener('load',update);addEventListener('resize',update);
|
|
19
|
+
addEventListener('message',e=>{if(e.source===frame?.contentWindow&&e.data?.type==='homie-join-ready')update();});
|
|
20
|
+
update();
|
|
21
|
+
}
|
|
22
|
+
export function joinAreaFrame() {
|
|
23
|
+
addEventListener('message',e=>{
|
|
24
|
+
if(e.source!==parent||e.data?.type!=='homie-join-area')return;
|
|
25
|
+
const {box,visible}=e.data;
|
|
26
|
+
document.body?.classList.toggle('homie-join-visible',!!visible);
|
|
27
|
+
for(const k of ['top','bottom','left','right','x','y','width','height']) {
|
|
28
|
+
const v=box?.[k];if(v!==undefined&&(!Number.isFinite(v)||v<0))return;
|
|
29
|
+
document.documentElement.style.setProperty('--homie-join-'+(['top','bottom','left','right'].includes(k)?'inset-':'')+k,(v??0)+'px');
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
addEventListener('DOMContentLoaded',()=>parent.postMessage({type:'homie-join-ready'},'*'));
|
|
33
|
+
}
|
|
34
|
+
export const JOIN_AREA_SHELL_JS=`(${joinAreaShell.toString()})();`;
|
|
35
|
+
export const JOIN_AREA_FRAME_JS=`(${joinAreaFrame.toString()})();`;
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -39,9 +39,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
|
39
39
|
// Filter before limiting: unrelated collections must not create a false hasMore
|
|
40
40
|
// or force the client through pages containing no matching occurrences.
|
|
41
41
|
const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
|
|
42
|
-
WHERE type=?1 AND seq>?2 AND seq<=?3
|
|
42
|
+
WHERE type=?1 AND seq>?2 AND seq<=?3 AND scope IS ?7
|
|
43
43
|
AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
|
|
44
|
-
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1).all()).results;
|
|
44
|
+
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1,feed.event==='record.changed'?null:caller.testScope??null).all()).results;
|
|
45
45
|
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
46
|
const events=page.map(row=>{const data=JSON.parse(row.data);if(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
47
47
|
const after=hasMore?page.at(-1).seq:start.through;
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {bookingHolds} from './booking-holds.mjs';
|
|
1
2
|
import {toolDatabase} from './tool-database.mjs';
|
|
2
3
|
import { selling } from './extensions.mjs';
|
|
3
4
|
import { withToolIdentity } from './tool-identity.mjs';
|
|
@@ -66,6 +67,8 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
66
67
|
const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
|
|
67
68
|
return Object.freeze({
|
|
68
69
|
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
70
|
+
bookings:bookingHolds(env,caller,cat),
|
|
71
|
+
events:Object.freeze({publish:args=>import('./business-events.mjs').then(m=>m.publishBusinessEvent(env,caller,definition,namespace,args))}),
|
|
69
72
|
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
70
73
|
shop:()=>office('/_studio/api/shop'),
|
|
71
74
|
database:toolDatabase(env.DB,namespace),
|