@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.
Files changed (69) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +68 -1
  4. package/bin/homie-studio.mjs +8 -2
  5. package/docs/shop-migration.md +9 -0
  6. package/docs/toolkit-batch.md +49 -0
  7. package/docs/toolkit-place-patterns.md +6 -0
  8. package/functions/FUNCTIONS.md +42 -1
  9. package/functions/functions.ts +4 -0
  10. package/guides/app/PLACES.md +146 -0
  11. package/guides/app/SKILL.md +3 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/lib/app-needs.mjs +59 -15
  15. package/lib/app-ready.mjs +1 -1
  16. package/lib/build.mjs +8 -2
  17. package/lib/check.mjs +40 -16
  18. package/lib/cloudflare.mjs +2 -2
  19. package/lib/dev-restart.mjs +25 -0
  20. package/lib/dev.mjs +7 -1
  21. package/lib/fork.mjs +47 -0
  22. package/lib/functions-build.mjs +3 -0
  23. package/lib/jobs.mjs +1 -0
  24. package/lib/mcp-tools.mjs +3 -3
  25. package/lib/needs-headless.mjs +4 -3
  26. package/lib/scaffold.mjs +29 -1
  27. package/lib/shop-links.mjs +7 -7
  28. package/lib/shop-mcp.mjs +88 -14
  29. package/lib/stripe-connect.mjs +46 -6
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +25 -3
  32. package/lib/upgrade-places.mjs +49 -0
  33. package/lib/upgrade.mjs +2 -1
  34. package/lib/worker-config.mjs +7 -3
  35. package/links/links.d.mts +5 -0
  36. package/links/links.d.ts +2 -0
  37. package/links/links.mjs +2 -0
  38. package/links/time.mjs +20 -0
  39. package/package.json +1 -1
  40. package/rules/rules.ts +1 -1
  41. package/shop/SHOP.md +27 -12
  42. package/shop/shop.ts +3 -3
  43. package/tools/tools.ts +13 -0
  44. package/worker/app-format.mjs +6 -3
  45. package/worker/app-records.mjs +21 -5
  46. package/worker/app-shell.mjs +28 -5
  47. package/worker/app-streams.mjs +57 -0
  48. package/worker/app-tools.mjs +44 -0
  49. package/worker/app-web.mjs +23 -0
  50. package/worker/booking-calendar.mjs +26 -0
  51. package/worker/booking-holds.mjs +100 -0
  52. package/worker/booking-options.mjs +23 -0
  53. package/worker/booking-resources.mjs +55 -0
  54. package/worker/build-id.mjs +1 -1
  55. package/worker/business-events.mjs +31 -0
  56. package/worker/customer-mcp.mjs +1 -1
  57. package/worker/event-resources.mjs +1 -1
  58. package/worker/functions.mjs +5 -0
  59. package/worker/index.mjs +54 -9
  60. package/worker/local-test.mjs +10 -0
  61. package/worker/mcp-events.mjs +3 -3
  62. package/worker/mcp-tools.mjs +23 -2
  63. package/worker/mcp.mjs +3 -3
  64. package/worker/office.mjs +27 -0
  65. package/worker/pages.mjs +4 -4
  66. package/worker/records-page.mjs +3 -3
  67. package/worker/shop-links.mjs +12 -0
  68. package/worker/shop.mjs +20 -5
  69. 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
+ }
@@ -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.51.8","sha256":"ac2d23a60cc25247d09af506854dec3345b9219d57dbde5f3b1abe5640abde84"});
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
+ }
@@ -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}`;
@@ -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 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>`;
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
- if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
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);
@@ -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
+ }
@@ -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
  }
@@ -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 tool.handler(args,{...context,request});
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>
@@ -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=`/${meta.id}/__game/?${params}`;
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
  }
@@ -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
+ }