@homie-rocks/studio 0.51.8 → 0.53.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 +59 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +68 -1
- package/bin/homie-studio.mjs +8 -2
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/docs/toolkit-place-patterns.md +6 -0
- package/functions/FUNCTIONS.md +42 -1
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +146 -0
- package/guides/app/SKILL.md +3 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/lib/app-needs.mjs +59 -15
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +8 -2
- package/lib/check.mjs +40 -16
- package/lib/cloudflare.mjs +2 -2
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +7 -1
- package/lib/fork.mjs +47 -0
- package/lib/functions-build.mjs +3 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +29 -1
- package/lib/shop-links.mjs +7 -7
- package/lib/shop-mcp.mjs +88 -14
- package/lib/stripe-connect.mjs +46 -6
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +7 -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/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +13 -0
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +21 -5
- package/worker/app-shell.mjs +28 -5
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +100 -0
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +54 -9
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +3 -3
- package/worker/mcp-tools.mjs +23 -2
- package/worker/mcp.mjs +3 -3
- package/worker/office.mjs +27 -0
- package/worker/pages.mjs +4 -4
- package/worker/records-page.mjs +3 -3
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +20 -5
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export const BOOKING_RESOURCES_MIGRATION_FILE='0030_booking_resources.sql';
|
|
2
|
+
const capacityCheck=`
|
|
3
|
+
SELECT RAISE(ABORT,'resource capacity unavailable') WHERE EXISTS (
|
|
4
|
+
WITH wanted AS (SELECT json_extract(value,'$.resource') resource,json_extract(value,'$.collection') collection,json_extract(value,'$.start') start,json_extract(value,'$.end') end,json_extract(value,'$.units') units FROM json_each(NEW.allocation)),
|
|
5
|
+
existing AS (SELECT json_extract(a.value,'$.resource') resource,json_extract(a.value,'$.collection') collection,json_extract(a.value,'$.start') start,json_extract(a.value,'$.end') end,json_extract(a.value,'$.units') units FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=NEW.app AND b.id!=NEW.id AND b.state IN ('held','confirmed')),
|
|
6
|
+
points AS (SELECT resource,collection,start point FROM wanted UNION SELECT resource,collection,start FROM existing)
|
|
7
|
+
SELECT 1 FROM points p WHERE EXISTS(SELECT 1 FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point)
|
|
8
|
+
AND (COALESCE((SELECT SUM(units) FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point),0)+COALESCE((SELECT SUM(units) FROM existing e WHERE e.resource=p.resource AND e.collection=p.collection AND e.start<=p.point AND e.end>p.point),0))>
|
|
9
|
+
COALESCE((SELECT json_extract(value,'$.capacity') FROM app_records WHERE app=NEW.app AND collection=p.collection AND id=p.resource),0));`;
|
|
10
|
+
export const BOOKING_RESOURCES_MIGRATION=`ALTER TABLE shop_bookings ADD COLUMN allocation TEXT NOT NULL DEFAULT '[]';
|
|
11
|
+
ALTER TABLE shop_bookings ADD COLUMN choices TEXT NOT NULL DEFAULT '{}';
|
|
12
|
+
${['INSERT','UPDATE OF state,allocation'].map((op,i)=>`CREATE TRIGGER booking_resource_${i} BEFORE ${op} ON shop_bookings WHEN NEW.state IN ('held','confirmed') BEGIN ${capacityCheck} END;`).join('\n')}
|
|
13
|
+
CREATE TRIGGER booking_resource_move BEFORE UPDATE OF slot,quantity ON shop_bookings WHEN (OLD.slot!=NEW.slot OR OLD.quantity!=NEW.quantity) AND OLD.allocation!='[]' AND OLD.state IN ('held','confirmed') BEGIN SELECT RAISE(ABORT,'resource booking move requires release and a new allocation'); END;
|
|
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
|
+
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
|
+
`;
|
|
17
|
+
export async function allocateResources(env,app,spec,slot,args) {
|
|
18
|
+
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
|
+
if(!row)throw new Error('Slot unavailable');
|
|
20
|
+
const data=JSON.parse(row.value),option=args.option===undefined?null:data.options?.find(o=>o.id===args.option);
|
|
21
|
+
if(args.option!==undefined&&!option)throw new Error('Choose a declared option');
|
|
22
|
+
if(data.requireOption&&!option)throw new Error('Choose an option');
|
|
23
|
+
const parts=option?.components??(data.resources?[{resources:data.resources,skills:data.skills,attributes:data.attributes}]:[]);
|
|
24
|
+
const components=[...parts];
|
|
25
|
+
for(const [id,count] of Object.entries(args.addons??{})) {
|
|
26
|
+
const addon=data.addons?.find(a=>a.id===id);if(!addon)throw new Error('Unknown add-on');
|
|
27
|
+
for(const component of addon.components??[])components.push({...component,units:(component.units??1)*count});
|
|
28
|
+
}
|
|
29
|
+
if(!components.length){if(args.resource)throw new Error('This slot has no declared resources');return [];}
|
|
30
|
+
if(!spec.resources)throw new Error('Declare booking.resources collection');
|
|
31
|
+
const start=Date.parse(data.start),end=Date.parse(data.end);
|
|
32
|
+
if(!Number.isFinite(start)||!Number.isFinite(end)||end<=start)throw new Error('Resource slot needs start and end timestamps');
|
|
33
|
+
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
|
+
const allocations=[];
|
|
36
|
+
for(const [index,c] of components.entries()) {
|
|
37
|
+
if(!Array.isArray(c.resources)||!c.resources.length||c.resources.length>64)throw new Error('Declare candidate resources');
|
|
38
|
+
const candidates=index===0&&args.resource?[args.resource]:c.resources;
|
|
39
|
+
if(candidates.some(id=>!c.resources.includes(id)))throw new Error('Preferred resource is unavailable for this option');
|
|
40
|
+
const from=start+(c.offsetMinutes??0)*60000,to=c.durationMinutes?from+c.durationMinutes*60000:end,units=(c.units??1)*(args.quantity??1);
|
|
41
|
+
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
|
+
let selected,qualified=false;
|
|
43
|
+
for(const resource of candidates) {
|
|
44
|
+
const record=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.resources,resource).first();
|
|
45
|
+
if(!record)continue;const r=JSON.parse(record.value);
|
|
46
|
+
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
|
+
qualified=true;const overlapping=[...active,...allocations].filter(a=>a.resource===resource&&a.collection===spec.resources&&a.start<to&&a.end>from);
|
|
48
|
+
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;
|
|
49
|
+
selected={resource,collection:spec.resources,start:from,end:to,units};break;
|
|
50
|
+
}
|
|
51
|
+
if(!selected)throw new Error(qualified?'Resource capacity unavailable for these dates':'No resource matches the required skills or attributes');
|
|
52
|
+
allocations.push(selected);
|
|
53
|
+
}
|
|
54
|
+
return allocations;
|
|
55
|
+
}
|
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.53.0","sha256":"c43b8fcfbd61ff241c86555b2806cd599baf71513376d3958c379b04697dd923"});
|
|
@@ -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
|
+
}
|
package/worker/customer-mcp.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function customerTools(env,cat,origin) {
|
|
|
26
26
|
return [
|
|
27
27
|
{name:'studio_catalogue',description:'Browse the public shop, paid parts and app goods. Prices use the studio’s currency minor units.',audience:'public',inputSchema:object(),handler:async(_args,ctx)=>({shop:await customerShop(env,cat,origin,ctx.caller,'/api/shop'),resources:await listOffers(env,origin)})},
|
|
28
28
|
{name:'studio_cart',description:'Quote a cart for agent payment. Retain the returned private account cookies to use guest purchases. Approve this immutable offerVersion before purchase.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>quoteCart(env,cat,origin,ctx.caller,args,ctx.request)},
|
|
29
|
-
{name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
|
|
29
|
+
{name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:{...cartSchema,properties:{...cartSchema.properties,reference:str,booking:object({collection:str,slot:str,hold:str,token:str},['collection','slot'])}},handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
|
|
30
30
|
{name:'studio_purchase',description:'Buy an approved offer with the MCP payment binding. Generate private random 32-byte hex buyer and claim values; retain the claim for retries. For x402 use the returned HTTP resource address.',audience:'public',inputSchema:{...purchaseSchema,properties:{...purchaseSchema.properties,checkout:{type:'boolean'}}},protocol:true,handler:async(args,ctx)=>{if(args.checkout&&args.kind==='cart'){const resource=await resourceKind('cart').get(env,args.resource,args.version);if(!resource)throw new Error('Cart unavailable');const result=await customerShop(env,cat,origin,{...ctx.caller,id:resource.player},'/api/shop/buy',{lines:resource.lines.map(l=>({item:l.item.id,quantity:l.quantity,amount:l.amount}))});return {content:[{type:'text',text:JSON.stringify(result)}],...(result.ok?{}:{isError:true})};}return buy(env,cat,origin,args,ctx.paymentExtra);}},
|
|
31
31
|
{name:'studio_customer_refund',description:'Request a refund for your shop order under the studio refund policy.',audience:'signed-in',inputSchema:object({order:str,line:str},['order']),handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/refund',args)},
|
|
32
32
|
{name:'studio_purchase_refund',description:'Request a resource or service refund with the original private purchase claim.',audience:'public',inputSchema:object({claim:{type:'string',pattern:'^[a-f0-9]{64}$'}},['claim']),handler:async({claim})=>{const url=new URL('/api/purchases/refund',origin);const response=await purchaseRoutes(new Request(url,{method:'POST',headers:{authorization:'Bearer '+claim,'content-type':'application/json'},body:'{}'}),env,url,cat);return response.json();}},
|
|
@@ -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,5 +1,6 @@
|
|
|
1
1
|
import {appGraphicsFallback} from './app-graphics.mjs';
|
|
2
2
|
import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
|
|
3
|
+
import {loadShopLinks} from './shop-links.mjs';
|
|
3
4
|
import { directoryUrl } from './directory-choice.mjs';
|
|
4
5
|
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
5
6
|
import {recordsPage} from './records-page.mjs';
|
|
@@ -122,7 +123,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
|
|
|
122
123
|
import { ownerRoutes } from './stats-page.mjs';
|
|
123
124
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
124
125
|
import {
|
|
125
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
126
|
+
appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
126
127
|
ticketFor, ticketSub, usePlayers, verifyControl,
|
|
127
128
|
} from './office.mjs';
|
|
128
129
|
|
|
@@ -153,7 +154,7 @@ export { mergeSaves } from './saves.mjs';
|
|
|
153
154
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
154
155
|
const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
155
156
|
/** A game's own files, framed by its play page: never x-frame-options DENY. */
|
|
156
|
-
const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
|
|
157
|
+
const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
|
|
157
158
|
const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
|
|
158
159
|
/** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
|
|
159
160
|
const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
|
|
@@ -538,7 +539,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
538
539
|
};
|
|
539
540
|
let html = await res.text();
|
|
540
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>` : '';
|
|
541
|
-
const
|
|
542
|
+
const assetBase = meta.kind === 'app' && !url.pathname.includes('/__assets/') && launchOf(meta, await settingsOf(env, {fresh:true}), env) !== 'public' ? `<base href="/${game}/__assets/${await appAssetTicket(env,game)}/">` : '';
|
|
543
|
+
const head = assetBase + `<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>`;
|
|
542
544
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
543
545
|
return new Response(html, {
|
|
544
546
|
headers: {
|
|
@@ -621,6 +623,7 @@ async function removeReferralLeftovers(env) {
|
|
|
621
623
|
|
|
622
624
|
export default {
|
|
623
625
|
async scheduled(event, env, ctx) {
|
|
626
|
+
env=await loadShopLinks(env);
|
|
624
627
|
const maintenance=(async () => {
|
|
625
628
|
await removeReferralLeftovers(env);
|
|
626
629
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
@@ -632,8 +635,11 @@ export default {
|
|
|
632
635
|
await maintenance;
|
|
633
636
|
},
|
|
634
637
|
async fetch(request, env, ctx) {
|
|
638
|
+
env=await loadShopLinks(env,new URL(request.url).origin);
|
|
635
639
|
await removeReferralLeftovers(env);
|
|
636
640
|
const url = new URL(request.url);
|
|
641
|
+
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
|
|
642
|
+
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
|
|
637
643
|
await (await import('./functions.mjs')).syncFunctions(env);
|
|
638
644
|
const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
|
|
639
645
|
// Some routes reconcile shop state in waitUntil after returning their page.
|
|
@@ -662,6 +668,16 @@ export default {
|
|
|
662
668
|
if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
|
|
663
669
|
}
|
|
664
670
|
}
|
|
671
|
+
if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
|
|
672
|
+
const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
|
|
673
|
+
let body=res.body;
|
|
674
|
+
if(/text\/html/i.test(headers.get('content-type')??'')) {
|
|
675
|
+
const html=await res.text(),banner='<aside role="status" style="position:fixed;z-index:2147483647;top:0;left:0;right:0;text-align:center;background:#ffda44;color:#171717;padding:6px;font:bold 14px system-ui;pointer-events:none">TEST / CONCEPT</aside>';
|
|
676
|
+
body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
|
|
677
|
+
headers.delete('content-length');
|
|
678
|
+
}
|
|
679
|
+
res=new Response(body,{status:res.status,headers});
|
|
680
|
+
}
|
|
665
681
|
if (appLobby || appRecords) return appCors(request, res);
|
|
666
682
|
return res;
|
|
667
683
|
},
|
|
@@ -839,7 +855,7 @@ async function route(request, env, ctx) {
|
|
|
839
855
|
if (own.ok) return own;
|
|
840
856
|
const cat = await getCat();
|
|
841
857
|
const method = request.method;
|
|
842
|
-
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
|
|
858
|
+
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
|
|
843
859
|
const all = await getAll();
|
|
844
860
|
if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
|
|
845
861
|
const full = path === '/llms-full.txt';
|
|
@@ -924,6 +940,10 @@ async function route(request, env, ctx) {
|
|
|
924
940
|
return postPage(cat, post, { origin: url.origin });
|
|
925
941
|
}
|
|
926
942
|
|
|
943
|
+
if(parts[0]==='games' && parts.length>2) {
|
|
944
|
+
const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
|
|
945
|
+
if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
|
|
946
|
+
}
|
|
927
947
|
const game = parts[0];
|
|
928
948
|
if (game && GAME_ID.test(game)) {
|
|
929
949
|
const all = await getAll();
|
|
@@ -946,7 +966,26 @@ async function route(request, env, ctx) {
|
|
|
946
966
|
const launch = launchOf(meta, settings, env);
|
|
947
967
|
const max = seatsFor(meta, settings);
|
|
948
968
|
if (meta.kind === 'app') {
|
|
949
|
-
|
|
969
|
+
const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
|
|
970
|
+
if(asset) {
|
|
971
|
+
if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
|
|
972
|
+
const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
|
|
973
|
+
response.headers.set('cache-control','private, no-store');
|
|
974
|
+
response.headers.set('referrer-policy','no-referrer');
|
|
975
|
+
return response;
|
|
976
|
+
}
|
|
977
|
+
const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
|
|
978
|
+
if (launch !== 'public' && !frameTicket && !(await accessOf(request, env, game, launch)).ok && (await (await import('./local-test.mjs')).localSession(request,env))?.app!==game) return json({ ok: false, error: 'not-found' }, 404);
|
|
979
|
+
if(launch==='public') {
|
|
980
|
+
const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
|
|
981
|
+
if(web)return web;
|
|
982
|
+
}
|
|
983
|
+
if(sub==='api/app/live') {
|
|
984
|
+
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
985
|
+
const {appHub}=await import('./app-streams.mjs');
|
|
986
|
+
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')})});
|
|
987
|
+
}
|
|
988
|
+
if(sub==='api/app/tools')return (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools());
|
|
950
989
|
const api = await appRecordsRoute(request, env, meta, url, sub);
|
|
951
990
|
if (api) return api;
|
|
952
991
|
if (['play', 'tv', 'watch'].includes(sub)) {
|
|
@@ -956,7 +995,7 @@ async function route(request, env, ctx) {
|
|
|
956
995
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
957
996
|
return json({ ok: false, error: a.error }, a.status);
|
|
958
997
|
}
|
|
959
|
-
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game));
|
|
998
|
+
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game):null);
|
|
960
999
|
}
|
|
961
1000
|
}
|
|
962
1001
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -1101,11 +1140,11 @@ async function route(request, env, ctx) {
|
|
|
1101
1140
|
const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
|
|
1102
1141
|
let shopQr = null;
|
|
1103
1142
|
if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
|
|
1104
|
-
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 });
|
|
1143
|
+
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 });
|
|
1105
1144
|
}
|
|
1106
1145
|
await countVisit(request, env, ctx, game, 'play');
|
|
1107
1146
|
shareDaily(cat, url, ctx);
|
|
1108
|
-
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 }) });
|
|
1147
|
+
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 }) });
|
|
1109
1148
|
for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
|
|
1110
1149
|
}
|
|
1111
1150
|
if (sub === 'watch') {
|
|
@@ -1123,7 +1162,7 @@ async function route(request, env, ctx) {
|
|
|
1123
1162
|
const ticket = d.holder ? await ticketFor(env, game, d.holder) : null;
|
|
1124
1163
|
await countVisit(request, env, ctx, game, 'watch');
|
|
1125
1164
|
shareDaily(cat, url, ctx);
|
|
1126
|
-
return watchPage(cat, meta, { origin: url.origin, room: asked, ticket, policy, owner: d.owner, ...(await chatWho(env, game, srv, d.acct)) });
|
|
1165
|
+
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)) });
|
|
1127
1166
|
}
|
|
1128
1167
|
if (sub === 'api/watch') {
|
|
1129
1168
|
if (!door.ok || watchOf(meta) === 'off') return json({ ok: false, error: 'not-found' }, 404);
|
|
@@ -2604,6 +2643,12 @@ export class Lobby {
|
|
|
2604
2643
|
|
|
2605
2644
|
/** A request that throws is said with what it was (never its body) and answered as a failure, not left uncaught. */
|
|
2606
2645
|
async fetch(request) {
|
|
2646
|
+
if(new URL(request.url).pathname==='/__app_records') {
|
|
2647
|
+
const {AppRecordStreams}=await import('./app-streams.mjs');
|
|
2648
|
+
this.appStreams??=new AppRecordStreams(this.env,this.ctx);
|
|
2649
|
+
const input=await request.json();
|
|
2650
|
+
return input.op==='open'?this.appStreams.open(input):this.appStreams.wake(input.meta);
|
|
2651
|
+
}
|
|
2607
2652
|
if(new URL(request.url).pathname==='/__studio_events') {
|
|
2608
2653
|
const {EventStreams}=await import('./event-resources.mjs');
|
|
2609
2654
|
this.eventStreams??=new EventStreams(this.env,this.ctx);
|
package/worker/local-test.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
2
|
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
3
|
export async function localCaller(request, env) {
|
|
4
|
+
const remote=await remoteSession(request,env);
|
|
5
|
+
if(remote)return {id:`check-${remote.app}-${remote.role}`,owner:false,client:null,localTest:true,remoteTest:true,roles:{[remote.app]:[remote.role]},testApp:remote.app,testScope:remote.scope};
|
|
4
6
|
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
7
|
const session=await localSession(request,env);
|
|
6
8
|
// A supplied but invalid signed session must never fall back to owner.
|
|
@@ -9,6 +11,7 @@ export async function localCaller(request, env) {
|
|
|
9
11
|
return {id:'local-test-owner',owner:true,client:null};
|
|
10
12
|
}
|
|
11
13
|
export async function localSession(request, env) {
|
|
14
|
+
const remote=await remoteSession(request,env);if(remote)return remote;
|
|
12
15
|
if (!local(request, env)) return null;
|
|
13
16
|
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
14
17
|
if (!token) return null;
|
|
@@ -21,3 +24,10 @@ export async function localSession(request, env) {
|
|
|
21
24
|
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
22
25
|
} catch { return null; }
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
async function remoteSession(request,env) {
|
|
29
|
+
const token=/(?:^|;\s*)homie_check=([^;]+)/.exec(request.headers.get('cookie')??'')?.[1];
|
|
30
|
+
if(!token)return null;
|
|
31
|
+
const session=await (await import('./office.mjs')).checkSessionTicket(env,null,token);
|
|
32
|
+
return session?.origin===new URL(request.url).origin?{...session,remoteTest:true}:null;
|
|
33
|
+
}
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -39,11 +39,11 @@ 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.remoteTest)&&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
|
-
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));});
|
|
46
|
+
const events=page.map(row=>{const data=JSON.parse(row.data);if((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&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;
|
|
48
48
|
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
|
|
49
49
|
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
|
|
2
|
+
import {bookingHolds} from './booking-holds.mjs';
|
|
1
3
|
import {toolDatabase} from './tool-database.mjs';
|
|
2
4
|
import { selling } from './extensions.mjs';
|
|
3
5
|
import { withToolIdentity } from './tool-identity.mjs';
|
|
@@ -15,7 +17,7 @@ export async function allowed(audience, caller, env) {
|
|
|
15
17
|
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());
|
|
16
18
|
if (audience === 'owner') return caller.owner === true;
|
|
17
19
|
if (audience && typeof audience === 'object' && audience.app && audience.role) {
|
|
18
|
-
if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
|
|
20
|
+
if((env.HOMIE_LOCAL_TEST_SECRET || caller.remoteTest) && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
|
|
19
21
|
return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
|
|
20
22
|
}
|
|
21
23
|
return false;
|
|
@@ -37,9 +39,11 @@ export async function currentCaller(env, props) {
|
|
|
37
39
|
}
|
|
38
40
|
export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
39
41
|
const records = async ({app,role,collection,id,operation='read',data,version}) => {
|
|
42
|
+
if(operation!=='read'&&!id)throw Object.assign(new Error('Put id at the top level of the arguments, alongside app and collection (1–64 letters, digits, underscore or hyphen).'),{name:'StudioOperationError'});
|
|
43
|
+
if(caller.remoteTest&&app!==caller.testApp)throw new Error('Test session belongs to another app');
|
|
40
44
|
const meta = cat.games.find(g=>g.id===app && g.kind==='app');
|
|
41
45
|
if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw Object.assign(new Error('Unknown app or collection; collection names start with a lowercase letter and use lowercase letters, digits and hyphens (1–40). Record id: 1–64 letters, digits, underscore or hyphen.'),{name:'StudioOperationError'});
|
|
42
|
-
if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
46
|
+
if (!caller.owner && !caller.remoteTest && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
43
47
|
if(!role) {
|
|
44
48
|
const candidates=[];
|
|
45
49
|
for(const [name,declared] of Object.entries(meta.roles??{})) {
|
|
@@ -66,6 +70,8 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
66
70
|
const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
|
|
67
71
|
return Object.freeze({
|
|
68
72
|
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
73
|
+
bookings:bookingHolds(env,caller,cat),
|
|
74
|
+
events:Object.freeze({publish:args=>import('./business-events.mjs').then(m=>m.publishBusinessEvent(env,caller,definition,namespace,args))}),
|
|
69
75
|
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
70
76
|
shop:()=>office('/_studio/api/shop'),
|
|
71
77
|
database:toolDatabase(env.DB,namespace),
|
|
@@ -87,6 +93,13 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
87
93
|
export function builtins(env,cat,origin) {
|
|
88
94
|
const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
|
|
89
95
|
const tools=[
|
|
96
|
+
tool('studio_check_session','Owner: create a short-lived app-role test session with isolated records. Never grants global owner access.','owner',object({app:str,role:str,scope:{type:'string',pattern:'^check-[a-zA-Z0-9-]{1,64}$'}},['app','role','scope']),async(args,ctx)=>{
|
|
97
|
+
if(!ctx.caller.owner)throw new Error('Owner required');
|
|
98
|
+
const meta=cat.games.find(g=>g.id===args.app&&g.kind==='app');
|
|
99
|
+
if(!meta?.roles?.[args.role])throw new Error('Name a declared app role');
|
|
100
|
+
const token=await (await import('./office.mjs')).checkSessionTicket(env,{...args,origin});
|
|
101
|
+
return {token,expiresIn:900};
|
|
102
|
+
}),
|
|
90
103
|
tool('studio_events_clear','Owner: permanently clear retained events and deliveries through a sequence number. Newer events remain.','owner',object({through:{type:'integer',minimum:0}},['through']),async({through},ctx)=>{
|
|
91
104
|
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
92
105
|
if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
|
|
@@ -163,6 +176,14 @@ export function builtins(env,cat,origin) {
|
|
|
163
176
|
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
164
177
|
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
165
178
|
}),
|
|
179
|
+
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)=>{
|
|
180
|
+
const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
|
|
181
|
+
if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
|
|
182
|
+
const minutes=Number.isSafeInteger(spec.holdMinutes)&&spec.holdMinutes>0?Math.min(spec.holdMinutes,1440):15;
|
|
183
|
+
if(!Number.isSafeInteger(args.expiresAt)||args.expiresAt<=Date.now()||args.expiresAt>Date.now()+minutes*60000)throw Object.assign(new Error(`Hold expiry must be within ${minutes} minutes`),{name:'StudioOperationError'});
|
|
184
|
+
await ctx.records({app:args.app,collection:spec.slots,id:args.slot});
|
|
185
|
+
return ctx.bookings.hold(args);
|
|
186
|
+
}),
|
|
166
187
|
tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'1–64 letters, digits, underscore or hyphen'},operation:{enum:['read','create','update','delete']},data:{type:'object',propertyNames:{pattern:'^[a-z][a-z0-9_]*$'},description:'Field names start with a lowercase letter and use lowercase letters, digits and underscore; discover fields with app_schema.'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
|
|
167
188
|
];
|
|
168
189
|
for(const [name,path,description] of [
|
package/worker/mcp.mjs
CHANGED
|
@@ -123,7 +123,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
123
123
|
if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
|
|
124
124
|
s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
|
|
125
125
|
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
126
|
-
const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
126
|
+
const text=await tool.handler(args,services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool));
|
|
127
127
|
return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
|
|
128
128
|
}));continue;
|
|
129
129
|
}
|
|
@@ -135,10 +135,10 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
135
135
|
const fresh=await identity();
|
|
136
136
|
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
137
137
|
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
138
|
-
const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
|
|
138
|
+
const context=services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool);
|
|
139
139
|
if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
|
|
140
140
|
if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
|
|
141
|
-
const value=await
|
|
141
|
+
const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
|
|
142
142
|
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
143
143
|
outcome='ok';return {content:[{type:'text',text}]};
|
|
144
144
|
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
|
package/worker/office.mjs
CHANGED
|
@@ -1377,3 +1377,30 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1377
1377
|
}
|
|
1378
1378
|
return null;
|
|
1379
1379
|
}
|
|
1380
|
+
|
|
1381
|
+
/** Asset-only capability: cannot authenticate records, tools or room sockets. */
|
|
1382
|
+
export async function appAssetTicket(env, app, ticket) {
|
|
1383
|
+
const key=await officeKey(env);if(!key)return null;
|
|
1384
|
+
if(ticket===undefined) {
|
|
1385
|
+
const exp=(Date.now()+5*60_000).toString(36);
|
|
1386
|
+
return `${exp}.${(await mac(key,`app-assets|${app}|${exp}`)).slice(0,32)}`;
|
|
1387
|
+
}
|
|
1388
|
+
const m=/^([a-z0-9]+)\.([A-Za-z0-9_-]{32})$/.exec(ticket);
|
|
1389
|
+
return Boolean(m && parseInt(m[1],36)>Date.now() && same((await mac(key,`app-assets|${app}|${m[1]}`)).slice(0,32),m[2]));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
|
|
1393
|
+
export async function checkSessionTicket(env, data, token) {
|
|
1394
|
+
const key=await officeKey(env);if(!key)return null;
|
|
1395
|
+
if(token===undefined) {
|
|
1396
|
+
const payload=b64url(new TextEncoder().encode(JSON.stringify({...data,expires:Date.now()+15*60_000})));
|
|
1397
|
+
return payload+'.'+await mac(key,'check-session|'+payload);
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
const [payload,signature,...extra]=token.split('.');
|
|
1401
|
+
if(extra.length||!signature||!same(await mac(key,'check-session|'+payload),signature))return null;
|
|
1402
|
+
const session=JSON.parse(atob(payload.replaceAll('-','+').replaceAll('_','/')));
|
|
1403
|
+
if(session.expires<=Date.now()||!Number.isSafeInteger(session.expires)||!/^check-[a-zA-Z0-9-]{1,64}$/.test(session.scope)||!GAME_ID.test(session.app)||!/^[a-z][a-z0-9-]{0,39}$/.test(session.role))return null;
|
|
1404
|
+
return session;
|
|
1405
|
+
}catch{return null;}
|
|
1406
|
+
}
|
package/worker/pages.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export function sharePlaces(value) {
|
|
|
122
122
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
123
123
|
* covers the middle of the screen or a thumb.
|
|
124
124
|
*/
|
|
125
|
-
export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
125
|
+
export function playPage(cat, g, { test = false, embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
126
126
|
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
127
127
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
128
128
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
@@ -214,7 +214,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
214
214
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
215
215
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
216
216
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
217
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
217
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', test, appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
218
218
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
219
219
|
const joinCorner = corner('join', 'bottom-right');
|
|
220
220
|
const first = places.desk;
|
|
@@ -1173,10 +1173,10 @@ const OWNER_JS = String.raw`(function () {
|
|
|
1173
1173
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
1174
1174
|
* is the play door's). Every name a player typed is set with textContent.
|
|
1175
1175
|
*/
|
|
1176
|
-
export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1176
|
+
export function watchPage(cat, g, { test = false, origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1177
1177
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
1178
1178
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
1179
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1179
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', test, screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1180
1180
|
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
|
|
1181
1181
|
return layoutless(`Watch ${g.name}`, `
|
|
1182
1182
|
<header class="wtop" data-top>
|
package/worker/records-page.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
|
5
5
|
import {RECORDS_PREFS_JS} from './records-prefs.mjs';
|
|
6
6
|
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
7
7
|
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
8
|
-
export function recordsPage(meta,url,cat={},shop=null) {
|
|
8
|
+
export function recordsPage(meta,url,cat={},shop=null,test=false,assetTicket=null) {
|
|
9
9
|
const params=new URLSearchParams(url.search);params.delete('access');params.set('pf','1');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
|
|
10
10
|
const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
|
|
11
|
-
const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
12
|
-
const source
|
|
11
|
+
const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
12
|
+
const source=assetTicket?`/${meta.id}/__assets/${assetTicket}/index.html?${params}`:`/${meta.id}/__game/?${params}`;
|
|
13
13
|
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${RECORDS_PREFS_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
14
14
|
}
|
package/worker/shop-links.mjs
CHANGED
|
@@ -45,3 +45,15 @@ export async function rememberedMoney(env, payment, mode) {
|
|
|
45
45
|
const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
|
|
46
46
|
return rows.map((r) => JSON.parse(r.value));
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** Asset configuration overrides legacy secrets, including an explicitly closed setup.
|
|
50
|
+
* Return a request-local env: never mutate shared bindings or cache across deployments. */
|
|
51
|
+
export async function loadShopLinks(env, origin='https://studio.invalid') {
|
|
52
|
+
if(!env.STRIPE_SHOP_LINKS_ASSET)return env;
|
|
53
|
+
let value;
|
|
54
|
+
try {
|
|
55
|
+
const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
|
|
56
|
+
if(response.ok)value=await response.json();
|
|
57
|
+
} catch {}
|
|
58
|
+
return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
|
|
59
|
+
}
|