@homie-rocks/studio 0.52.0 → 0.54.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 (61) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +9 -3
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/guides/app/SKILL.md +2 -2
  9. package/guides/shop/SKILL.md +6 -0
  10. package/lib/app-needs.mjs +27 -11
  11. package/lib/app-ready.mjs +1 -1
  12. package/lib/build.mjs +4 -3
  13. package/lib/check.mjs +47 -15
  14. package/lib/dev.mjs +9 -1
  15. package/lib/fork.mjs +47 -0
  16. package/lib/jobs.mjs +1 -0
  17. package/lib/mcp-tools.mjs +1 -1
  18. package/lib/needs-headless.mjs +4 -3
  19. package/lib/scaffold.mjs +27 -1
  20. package/lib/shop-links.mjs +39 -21
  21. package/lib/shop-mcp.mjs +13 -1
  22. package/lib/stripe-connect.mjs +37 -2
  23. package/lib/template-history.json +84 -0
  24. package/lib/tools-cli.mjs +25 -3
  25. package/lib/worker-config.mjs +1 -0
  26. package/package.json +1 -1
  27. package/rules/rules.ts +1 -1
  28. package/rules/view.ts +1 -1
  29. package/shop/SHOP.md +13 -9
  30. package/shop/shop.ts +3 -3
  31. package/tools/tools.ts +3 -2
  32. package/worker/app-format.mjs +6 -3
  33. package/worker/app-records.mjs +20 -6
  34. package/worker/app-shell.mjs +10 -5
  35. package/worker/app-streams.mjs +26 -9
  36. package/worker/app-tools.mjs +2 -1
  37. package/worker/app-web.mjs +23 -0
  38. package/worker/booking-calendar.mjs +26 -0
  39. package/worker/booking-holds.mjs +18 -5
  40. package/worker/booking-options.mjs +23 -0
  41. package/worker/booking-resources.mjs +55 -0
  42. package/worker/build-id.mjs +1 -1
  43. package/worker/chat-store.mjs +5 -4
  44. package/worker/customer-mcp.mjs +1 -1
  45. package/worker/event-resources.mjs +6 -1
  46. package/worker/index.mjs +74 -27
  47. package/worker/local-test.mjs +10 -0
  48. package/worker/mcp-events.mjs +2 -2
  49. package/worker/mcp-tools.mjs +20 -2
  50. package/worker/office.mjs +108 -6
  51. package/worker/operational-schema.mjs +30 -0
  52. package/worker/pages.mjs +7 -1
  53. package/worker/records-page.mjs +2 -2
  54. package/worker/request-db.mjs +53 -0
  55. package/worker/servers.mjs +5 -4
  56. package/worker/shop-links.mjs +1 -1
  57. package/worker/shop-page.mjs +1 -0
  58. package/worker/shop-rules.mjs +3 -2
  59. package/worker/shop-store.mjs +4 -3
  60. package/worker/shop.mjs +68 -8
  61. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {trackBackground,requestDatabase,databaseBinding,MUTATIONS} from './request-db.mjs';
1
2
  import {appGraphicsFallback} from './app-graphics.mjs';
2
3
  import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
3
4
  import {loadShopLinks} from './shop-links.mjs';
@@ -123,7 +124,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
123
124
  import { ownerRoutes } from './stats-page.mjs';
124
125
  import { playerRoutes, players as playerAccounts } from './players.mjs';
125
126
  import {
126
- accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
127
+ appRoleSession, appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
127
128
  ticketFor, ticketSub, usePlayers, verifyControl,
128
129
  } from './office.mjs';
129
130
 
@@ -154,7 +155,7 @@ export { mergeSaves } from './saves.mjs';
154
155
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
155
156
  const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
156
157
  /** A game's own files, framed by its play page: never x-frame-options DENY. */
157
- const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
158
+ const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
158
159
  const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
159
160
  /** 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. */
160
161
  const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
@@ -168,13 +169,15 @@ const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(
168
169
  const catalogueCache = new WeakMap();
169
170
  async function catalogue(env, origin) {
170
171
  try {
172
+ const cached=catalogueCache.get(env.ASSETS);
173
+ if(cached?.origin===origin && env.HOMIE_PREVIEW!=='1' && env.HOMIE_DEV!=='1')return cached.value;
171
174
  const res = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
172
175
  if (!res.ok) return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] };
173
176
  const tag = res.headers.get('etag');
174
177
  const hit = catalogueCache.get(env.ASSETS);
175
178
  if (tag && hit?.tag === tag && hit.origin === origin) { await res.body?.cancel(); return hit.value; }
176
179
  const value = await res.json();
177
- if (tag) catalogueCache.set(env.ASSETS, { tag, origin, value });
180
+ catalogueCache.set(env.ASSETS, { tag, origin, value });
178
181
  return value;
179
182
  } catch { return { studio: { name: env.STUDIO_NAME || 'Studio' }, games: [] }; }
180
183
  }
@@ -539,7 +542,14 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
539
542
  };
540
543
  let html = await res.text();
541
544
  const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
542
- const head = `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
545
+ let assetBase='';
546
+ const assetLaunch=meta.kind==='app'?launchOf(meta,await settingsOf(env,{fresh:true}),env):'public';
547
+ if(meta.kind==='app'&&!url.pathname.includes('/__assets/')&&assetLaunch!=='public') {
548
+ const ticket=await appAssetTicket(env,game,undefined,{request,meta,launch:assetLaunch});
549
+ if(!ticket)return new Response('Sign in again to load this private app.',{status:403,headers:{'cache-control':'no-store'}});
550
+ assetBase=`<base href="/${game}/__assets/${ticket}/">`;
551
+ }
552
+ 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>`;
543
553
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
544
554
  return new Response(html, {
545
555
  headers: {
@@ -615,16 +625,10 @@ function finish(res, path) {
615
625
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
616
626
  }
617
627
 
618
- // Repeat after migration: an older isolate may recreate these during a rolling deploy.
619
- async function removeReferralLeftovers(env) {
620
- if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
621
- }
622
-
623
628
  export default {
624
629
  async scheduled(event, env, ctx) {
625
630
  env=await loadShopLinks(env);
626
631
  const maintenance=(async () => {
627
- await removeReferralLeftovers(env);
628
632
  const cat = await catalogue(env, 'https://studio.invalid');
629
633
  await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
630
634
  await reconcileOrders(env, shopOf(cat));
@@ -634,18 +638,19 @@ export default {
634
638
  await maintenance;
635
639
  },
636
640
  async fetch(request, env, ctx) {
641
+ const staticPath=new URL(request.url).pathname;
642
+ if(['GET','HEAD'].includes(request.method)&&staticPath==='/_homie/site.js')return finish(await route(request,env,ctx),staticPath);
637
643
  env=await loadShopLinks(env,new URL(request.url).origin);
638
- await removeReferralLeftovers(env);
644
+ env={...env,DB:requestDatabase(env.DB,request)};
639
645
  const url = new URL(request.url);
640
- await (await import('./functions.mjs')).syncFunctions(env);
646
+ 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'}});
647
+ 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'});
641
648
  const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
642
649
  // Some routes reconcile shop state in waitUntil after returning their page.
643
650
  // Keep dispatch behind those writes too, preserving the runtime context's
644
651
  // methods and OAuth verification symbols through the proxy.
645
- const requestCtx=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
646
- if(key==='waitUntil')return work=>ctx.waitUntil(Promise.resolve(work).finally(dispatch));
647
- const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
648
- }}):ctx;
652
+ const background=trackBackground(ctx,()=>env.DB?.[MUTATIONS]?dispatch():undefined);
653
+ const requestCtx=background.context;
649
654
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
650
655
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
651
656
  const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
@@ -658,18 +663,36 @@ export default {
658
663
  res = json({ ok: false, error: 'failed' }, 500);
659
664
  } finally {
660
665
  // Refresh after the route has populated the catalogue cache.
661
- if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
666
+ if(env.DB&&env.LOBBY&&ctx?.waitUntil) {
667
+ const binding=databaseBinding(env.DB),mutation=url.pathname.startsWith('/_studio/')&&env.DB[MUTATIONS];
668
+ if(!toolCatalogues.has(binding)||mutation) {
669
+ toolCatalogues.add(binding);
670
+ ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>toolCatalogues.delete(binding)));
671
+ }
672
+ }
662
673
  // A route can commit a write and then fail. Its outbox still needs delivery.
663
674
  if(env.DB) {
664
- const delivery=dispatch();
675
+ const delivery=background.finish();
665
676
  if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
666
677
  }
667
678
  }
679
+ if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
680
+ const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
681
+ let body=res.body;
682
+ if(/text\/html/i.test(headers.get('content-type')??'')) {
683
+ 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>';
684
+ body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
685
+ headers.delete('content-length');
686
+ }
687
+ res=new Response(body,{status:res.status,headers});
688
+ }
668
689
  if (appLobby || appRecords) return appCors(request, res);
669
690
  return res;
670
691
  },
671
692
  };
672
693
 
694
+ // Refresh once per isolate/binding, and again after studio mutations. No shared pending I/O.
695
+ const toolCatalogues=new WeakSet();
673
696
  async function notifyTools(env,cat,origin) {
674
697
  if(!env.DB||!env.LOBBY)return;
675
698
  const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
@@ -842,7 +865,7 @@ async function route(request, env, ctx) {
842
865
  if (own.ok) return own;
843
866
  const cat = await getCat();
844
867
  const method = request.method;
845
- if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
868
+ if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
846
869
  const all = await getAll();
847
870
  if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
848
871
  const full = path === '/llms-full.txt';
@@ -927,14 +950,21 @@ async function route(request, env, ctx) {
927
950
  return postPage(cat, post, { origin: url.origin });
928
951
  }
929
952
 
953
+ if(parts[0]==='games' && parts.length>2) {
954
+ const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
955
+ if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
956
+ }
930
957
  const game = parts[0];
931
958
  if (game && GAME_ID.test(game)) {
932
959
  const all = await getAll();
933
960
  const meta = (all.games ?? []).find((g) => g.id === game);
934
961
  if (!meta) return fallback(request, env, url, getCat);
935
962
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
936
- const cat = await getCat();
937
963
  let sub = parts.slice(1).join('/');
964
+ // Game bundles were already public assets; only the document carries access context.
965
+ // Apps retain their session/role checks even for individual asset files.
966
+ if(read&&meta.kind!=='app'&&sub.startsWith('__game/')&&!['__game/','__game/index.html'].includes(sub))return gameAsset(request,env,url,game,sub.slice('__game/'.length));
967
+ const cat = await getCat();
938
968
  if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
939
969
  if (sub === '__restart') {
940
970
  if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
@@ -949,15 +979,32 @@ async function route(request, env, ctx) {
949
979
  const launch = launchOf(meta, settings, env);
950
980
  const max = seatsFor(meta, settings);
951
981
  if (meta.kind === 'app') {
952
- if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
982
+ const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
983
+ if(asset) {
984
+ if(!(await appAssetTicket(env,game,asset[1],{request,meta,launch})))return json({ok:false,error:'not-found'},404);
985
+ const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
986
+ response.headers.set('cache-control','private, no-store');
987
+ response.headers.set('referrer-policy','no-referrer');
988
+ return response;
989
+ }
990
+ const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
991
+ 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);
992
+ if(launch==='public') {
993
+ const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
994
+ if(web)return web;
995
+ }
996
+ const apiSession=async response=>{
997
+ const access=await appAccess(request,env,meta,url);
998
+ return access.ok?appRoleSession(response,request,env,meta,launch):response;
999
+ };
953
1000
  if(sub==='api/app/live') {
954
1001
  if(request.method!=='GET')return new Response('GET required',{status:405});
955
1002
  const {appHub}=await import('./app-streams.mjs');
956
- return appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})});
1003
+ return apiSession(await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'open',meta,url:request.url,headers:Object.fromEntries(request.headers),collections:url.searchParams.getAll('collection')})}));
957
1004
  }
958
- if(sub==='api/app/tools')return (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools());
1005
+ if(sub==='api/app/tools')return apiSession(await (await import('./app-tools.mjs')).appToolsRoute(request,env,meta,url,cat,await studioTools()));
959
1006
  const api = await appRecordsRoute(request, env, meta, url, sub);
960
- if (api) return api;
1007
+ if (api) return sub.startsWith('api/app/records/')?apiSession(api):api;
961
1008
  if (['play', 'tv', 'watch'].includes(sub)) {
962
1009
  let a;
963
1010
  try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
@@ -965,7 +1012,7 @@ async function route(request, env, ctx) {
965
1012
  if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
966
1013
  return json({ ok: false, error: a.error }, a.status);
967
1014
  }
968
- if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)));
1015
+ if(!meta.room)return appRoleSession(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,undefined,{request,meta,launch}):null),request,env,meta,launch);
969
1016
  }
970
1017
  }
971
1018
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
@@ -1110,11 +1157,11 @@ async function route(request, env, ctx) {
1110
1157
  const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
1111
1158
  let shopQr = null;
1112
1159
  if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = null; }
1113
- return playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, screen: true, joinUrl, qr, local, room, ticket, owner: d.owner, launch, server, shop: sh ? { ...sh, qr: shopQr, url: `${url.origin}/shop/?game=${game}` } : null });
1160
+ return appRoleSession(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 }),request,env,meta,launch);
1114
1161
  }
1115
1162
  await countVisit(request, env, ctx, game, 'play');
1116
1163
  shareDaily(cat, url, ctx);
1117
- const page=playPage(cat, meta, { test: Boolean(await (await import('./local-test.mjs')).localSession(request,env)), origin: url.origin, embed, preview: env.HOMIE_EMBED_PREVIEW === '1', ticket: embed ? null : ticket, owner: !embed && d.owner, launch, server, ...(embed ? {} : await chatWho(env, game, srv, d.acct)), shop: await availableShellShop(env, cat, game, { kids: pol.kids }) });
1164
+ const page=await appRoleSession(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 }) }),request,env,meta,launch);
1118
1165
  for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
1119
1166
  }
1120
1167
  if (sub === 'watch') {
@@ -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
+ }
@@ -41,9 +41,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
41
41
  const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
42
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,feed.event==='record.changed'?null:caller.testScope??null).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,4 @@
1
+ import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
1
2
  import {bookingHolds} from './booking-holds.mjs';
2
3
  import {toolDatabase} from './tool-database.mjs';
3
4
  import { selling } from './extensions.mjs';
@@ -16,7 +17,7 @@ export async function allowed(audience, caller, env) {
16
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());
17
18
  if (audience === 'owner') return caller.owner === true;
18
19
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
19
- 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));
20
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());
21
22
  }
22
23
  return false;
@@ -38,9 +39,11 @@ export async function currentCaller(env, props) {
38
39
  }
39
40
  export function services(env, cat, origin, caller, namespace, definition = {}) {
40
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');
41
44
  const meta = cat.games.find(g=>g.id===app && g.kind==='app');
42
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'});
43
- 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');
44
47
  if(!role) {
45
48
  const candidates=[];
46
49
  for(const [name,declared] of Object.entries(meta.roles??{})) {
@@ -90,6 +93,13 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
90
93
  export function builtins(env,cat,origin) {
91
94
  const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
92
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
+ }),
93
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)=>{
94
104
  if(!ctx.caller.owner)throw new Error('Owner only');
95
105
  if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
@@ -166,6 +176,14 @@ export function builtins(env,cat,origin) {
166
176
  const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
167
177
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
168
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
+ }),
169
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)),
170
188
  ];
171
189
  for(const [name,path,description] of [
package/worker/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  import { selling } from './extensions.mjs';
2
3
  import { purchaseOrderIfPresent } from './shop.mjs';
3
4
  import { toolIdentity } from './tool-identity.mjs';
@@ -170,6 +171,7 @@ export async function accountSub(request, env) {
170
171
 
171
172
  /** The owner: the signed-in owner's browser session, or (with player accounts) the owner's own account. */
172
173
  export async function isOwner(request, env) {
174
+ if(toolIdentity(request)?.owner)return true;
173
175
  if ((await ownerAllowed(request, env, { kinds: ['session'] })) === 'session') return true;
174
176
  try { return players?.isOwner ? (await players.isOwner(request, env)) === true : false; } catch { return false; }
175
177
  }
@@ -181,17 +183,17 @@ const settingsCache = new WeakMap();
181
183
  /** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0005. */
182
184
  export async function settingsOf(env, { fresh = false } = {}) {
183
185
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
184
- const hit = settingsCache.get(env.DB);
185
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
186
+ const hit = settingsCache.get(databaseBinding(env.DB));
187
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
186
188
  let map = new Map();
187
189
  try {
188
190
  const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
189
191
  map = new Map((results ?? []).map((r) => [r.game, r]));
190
192
  } catch { map = new Map(); }
191
- settingsCache.set(env.DB, { at: Date.now(), map });
193
+ settingsCache.set(databaseBinding(env.DB), { at: Date.now(), map });
192
194
  return map;
193
195
  }
194
- const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(env.DB); };
196
+ const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(databaseBinding(env.DB)); };
195
197
 
196
198
  /** A game's launch state: the owner's (D1), else its game.json `launch`, else public. A Preview enforces none. */
197
199
  export function launchOf(meta, settings, env) {
@@ -221,7 +223,7 @@ function hexBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i
221
223
  /** The studio's office secret (a random 32 bytes in D1 meta, made on first use), as an HMAC key; null without D1. */
222
224
  async function officeKey(env) {
223
225
  if (!env?.DB) return null;
224
- if (keys.has(env.DB)) return keys.get(env.DB);
226
+ if (keys.has(databaseBinding(env.DB))) return keys.get(databaseBinding(env.DB));
225
227
  let hex = null;
226
228
  try {
227
229
  hex = (await env.DB.prepare("SELECT value FROM meta WHERE key = 'office_key'").first())?.value ?? null;
@@ -232,7 +234,7 @@ async function officeKey(env) {
232
234
  } catch { hex = null; }
233
235
  if (!/^[a-f0-9]{64}$/.test(String(hex))) return null;
234
236
  const key = await crypto.subtle.importKey('raw', hexBytes(hex), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
235
- keys.set(env.DB, key);
237
+ keys.set(databaseBinding(env.DB), key);
236
238
  return key;
237
239
  }
238
240
  async function mac(key, text) { return b64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))); }
@@ -330,6 +332,8 @@ async function inviteOpen(env, id) {
330
332
  */
331
333
  export async function accessOf(request, env, game, launch) {
332
334
  if (launch === 'public') return { ok: true, sub: null, owner: false };
335
+ const caller=toolIdentity(request);
336
+ if(caller?.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.testApp===game)return {ok:true,sub:null,owner:false};
333
337
  if (await isOwner(request, env)) return { ok: true, sub: 'o', owner: true };
334
338
  if (launch === 'invite') {
335
339
  const pass = await passOf(request, env, game);
@@ -1377,3 +1381,101 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1377
1381
  }
1378
1382
  return null;
1379
1383
  }
1384
+
1385
+ /** Encrypted, HMAC-authenticated asset capability. Session hashes never enter URLs in plaintext.
1386
+ * Every use checks revocation, grants and role-link rotation in one D1 statement.
1387
+ */
1388
+ export async function appAssetTicket(env, app, ticket, {request,meta,launch=meta?.launch??'public',expectedRole,purpose='asset',renew=false}={}) {
1389
+ const key=await officeKey(env),issuing=ticket===undefined,denied=issuing?null:false;
1390
+ if(!key||!meta)return denied;
1391
+ const decode=text=>Uint8Array.from(atob(text.replaceAll('-','+').replaceAll('_','/')),c=>c.charCodeAt(0));
1392
+ const cipher=await crypto.subtle.importKey('raw',decode(await mac(key,'app-assets-encryption-v1')),'AES-GCM',false,['encrypt','decrypt']);
1393
+ let data;
1394
+ if(issuing) {
1395
+ if(!request)return null;
1396
+ const url=new URL(request.url),{appRole}=await import('./app-format.mjs');
1397
+ const role=appRole(meta,url),rights=meta.roles?.[role];if(!rights)return null;
1398
+ const owner=cookieValues(request,OWNER_COOKIE)[0],player=cookieValues(request,'studio_player')[0],pass=cookieValues(request,passCookie(app))[0],caller=toolIdentity(request);
1399
+ data={purpose,exp:Date.now()+5*60_000,role,rights:await sha256(JSON.stringify(rights)),hash:null,pass:pass?await sha256(pass):null,connection:null,person:null};
1400
+ if(owner||player){data.kind=owner?'owner':'player';data.hash=await sha256(owner??player);}
1401
+ else if(caller){
1402
+ if(caller.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.roles?.[app]?.includes(role)){data.kind='test';data.testApp=caller.testApp??app;}
1403
+ else {data.kind='tool';data.connection=caller.connection??null;data.person=caller.id??null;if(data.person?.startsWith('office-'))data.hash=data.person.slice(7);}
1404
+ } else if(launch==='invite'&&!rights.signIn&&pass){data.kind='pass';}
1405
+ else {
1406
+ const session=await (await import('./local-test.mjs')).localSession(request,env);
1407
+ if(session?.app!==app||session.role!==role)return null;
1408
+ data.kind='test';data.testApp=app;data.exp=Math.min(data.exp,session.expires??data.exp);
1409
+ }
1410
+ } else {
1411
+ try {
1412
+ const [iv,payload,signature,...extra]=ticket.split('.');
1413
+ if(extra.length||!signature||!same(await mac(key,`app-assets|${app}|${iv}|${payload}`),signature))return false;
1414
+ data=JSON.parse(new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv:decode(iv)},cipher,decode(payload))));
1415
+ }catch{return false;}
1416
+ }
1417
+ if(data.kind==='test') {
1418
+ const session=request&&await (await import('./local-test.mjs')).localSession(request,env);
1419
+ if(!issuing&&(!session||session.app!==app||session.role!==data.role||(session.scope??null)!==data.testScope))return denied;
1420
+ if(issuing)data.testScope=session?.scope??toolIdentity(request)?.testScope??null;
1421
+ }
1422
+ if(data.purpose!==purpose||(purpose==='handoff'&&!['owner','player'].includes(data.kind)))return denied;
1423
+ if(expectedRole&&data.role!==expectedRole)return denied;
1424
+ if(!Number.isSafeInteger(data.exp)||data.exp<=Date.now()||data.exp>Date.now()+5*60_000||!meta.roles?.[data.role]||data.rights!==await sha256(JSON.stringify(meta.roles[data.role])))return denied;
1425
+ const state=await env.DB.prepare(`SELECT
1426
+ (SELECT token FROM app_role_links WHERE app=?1 AND role=?2) link,
1427
+ EXISTS(SELECT 1 FROM stats_keys WHERE hash=?3 AND kind='session' AND expires_at>?4) owner,
1428
+ EXISTS(SELECT 1 FROM office_passes p JOIN office_invites i ON i.id=p.invite WHERE p.hash=?5 AND p.game=?1 AND p.expires_at>?4 AND i.revoked=0) pass,
1429
+ (SELECT p.id FROM players p WHERE p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4)) player,
1430
+ EXISTS(SELECT 1 FROM players p WHERE p.owner=1 AND (p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4) OR p.id=?6)) playerOwner,
1431
+ EXISTS(SELECT 1 FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player IN (?6,(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4))) granted,
1432
+ EXISTS(SELECT 1 FROM mcp_connections WHERE id=?7 AND person=?6 AND revoked=0) connected
1433
+ `).bind(app,data.role,data.hash??null,Date.now(),data.pass??null,data.person??null,data.connection??null).first();
1434
+ if(issuing&&purpose==='asset'&&launch==='invite'&&!meta.roles[data.role].signIn&&state.pass)data.kind='pass';
1435
+ const gate=state.owner||state.playerOwner||launch==='public'||launch==='invite'&&state.pass;
1436
+ const roleAllowed=!meta.roles[data.role].signIn||state.owner||state.playerOwner||state.granted;
1437
+ const authorized=data.kind==='owner'?state.owner:data.kind==='pass'?launch==='invite'&&!meta.roles[data.role].signIn&&state.pass:data.kind==='player'?state.player&&gate&&roleAllowed:data.kind==='tool'?state.connected&&gate&&roleAllowed:data.kind==='test'?data.testApp===app:false;
1438
+ if(!authorized)return denied;
1439
+ const link=state.link?await sha256(state.link):null;
1440
+ if(!issuing) {
1441
+ if(data.link!==link)return denied;
1442
+ if(!renew||purpose!=='handoff')return true;
1443
+ data.exp=Date.now()+5*60_000;
1444
+ }
1445
+ data.link=link;
1446
+ const iv=crypto.getRandomValues(new Uint8Array(12)),ivText=b64url(iv);
1447
+ const payload=b64url(new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv},cipher,new TextEncoder().encode(JSON.stringify(data)))));
1448
+ return `${ivText}.${payload}.${await mac(key,`app-assets|${app}|${ivText}|${payload}`)}`;
1449
+ }
1450
+
1451
+ /** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
1452
+ export async function checkSessionTicket(env, data, token) {
1453
+ const key=await officeKey(env);if(!key)return null;
1454
+ if(token===undefined) {
1455
+ const payload=b64url(new TextEncoder().encode(JSON.stringify({...data,expires:Date.now()+15*60_000})));
1456
+ return payload+'.'+await mac(key,'check-session|'+payload);
1457
+ }
1458
+ try {
1459
+ const [payload,signature,...extra]=token.split('.');
1460
+ if(extra.length||!signature||!same(await mac(key,'check-session|'+payload),signature))return null;
1461
+ const session=JSON.parse(atob(payload.replaceAll('-','+').replaceAll('_','/')));
1462
+ 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;
1463
+ return session;
1464
+ }catch{return null;}
1465
+ }
1466
+
1467
+ /** Same-origin role handoff for opening another screen, never the long-lived role URL secret. */
1468
+ export async function appRoleSession(response,request,env,meta,launch) {
1469
+ if(meta.kind!=='app')return response;
1470
+ const {appRole}=await import('./app-format.mjs'),role=appRole(meta,new URL(request.url));
1471
+ if(!meta.roles?.[role]?.signIn)return response;
1472
+ const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
1473
+ const ticket=(handoff&&await appAssetTicket(env,meta.id,handoff,{request,meta,launch,purpose:'handoff',expectedRole:role,renew:true}))||await appAssetTicket(env,meta.id,undefined,{request,meta,launch,purpose:'handoff'});
1474
+ if(ticket) {
1475
+ response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
1476
+ // LAN dev uses HTTP: a Secure cookie is discarded before the shell removes access.
1477
+ const secure=new URL(request.url).protocol==='https:'?'; Secure':'';
1478
+ response.headers.append('set-cookie',`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`);
1479
+ }
1480
+ return response;
1481
+ }
@@ -0,0 +1,30 @@
1
+ export const OPERATIONAL_MIGRATION_FILE='0031_request_and_tool_notifications.sql';
2
+ export const OPERATIONAL_MIGRATION=`
3
+ -- Removed features are cleaned once, never on the request path.
4
+ DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*';
5
+ INSERT OR IGNORE INTO meta(key,value) VALUES('tool-data-revision','0');
6
+ ${['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER tool_data_live_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
7
+ UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='tool-data-revision';
8
+ END;`).join('\n')}
9
+ `;
10
+
11
+ export const CACHE_MIGRATION_FILE='0032_metadata_revisions.sql';
12
+ export const CACHE_MIGRATION=`
13
+ INSERT OR IGNORE INTO meta(key,value) VALUES('metadata-revision','0');
14
+ ${['office_games','servers','chat_rules'].flatMap(table=>['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER ${table}_revision_${op.toLowerCase()} AFTER ${op} ON ${table} BEGIN
15
+ UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='metadata-revision';
16
+ END;`)).join('\n')}
17
+ `;
18
+ export const TOOL_NAMESPACE_MIGRATION_FILE='0033_tool_namespace_revisions.sql';
19
+ export const TOOL_NAMESPACE_MIGRATION=`
20
+ DROP TRIGGER tool_data_live_insert;
21
+ DROP TRIGGER tool_data_live_update;
22
+ DROP TRIGGER tool_data_live_delete;
23
+ DELETE FROM meta WHERE key='tool-data-revision';
24
+ CREATE TABLE tool_data_revisions(namespace TEXT PRIMARY KEY,revision INTEGER NOT NULL);
25
+ CREATE TABLE app_tool_namespaces(app TEXT NOT NULL,namespace TEXT NOT NULL,PRIMARY KEY(app,namespace));
26
+ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `CREATE TRIGGER tool_data_namespace_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
27
+ INSERT INTO tool_data_revisions VALUES(${row}.namespace,1) ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;
28
+ ${op==='UPDATE'?`INSERT INTO tool_data_revisions SELECT OLD.namespace,1 WHERE OLD.namespace!=NEW.namespace ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;`:''}
29
+ END;`;}).join('\n')}
30
+ `;
package/worker/pages.mjs CHANGED
@@ -494,7 +494,13 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
494
494
  ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
495
495
  ui.querySelector('[data-room-label]').textContent = labelOf(room);
496
496
  ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
497
- ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
497
+ var tv = new URL('/' + boot.game + '/tv', location.origin);
498
+ tv.searchParams.set('room', room);
499
+ if (boot.kind === 'app') {
500
+ var context = new URLSearchParams(location.search);
501
+ ['role'].forEach(function (key) { if (context.has(key)) tv.searchParams.set(key, context.get(key)); });
502
+ }
503
+ ui.querySelector('[data-bigscreen]').href = tv.pathname + tv.search;
498
504
  toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
499
505
  ui.querySelector('[data-invite]').addEventListener('click', function () {
500
506
  var data = { title: boot.name, text: (boot.appWords ? boot.appWords.open : 'Play') + ' ' + boot.name + ' with me: join my room.', url: link };
@@ -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,test=false) {
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
11
  const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
12
- const source=`/${meta.id}/__game/?${params}`;
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
  }
@@ -0,0 +1,53 @@
1
+ export const ORIGINAL_DB=Symbol('original database');
2
+ export const MUTATIONS=Symbol('request mutations');
3
+ export const databaseBinding=db=>db?.[ORIGINAL_DB]??db;
4
+ const metadata=new WeakMap();
5
+ const metadataQueries=new Set(['SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500','SELECT * FROM servers LIMIT 2000','SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000']);
6
+ /** Cache only completed metadata, stamped by a trigger-maintained revision. Pending
7
+ * D1 operations belong to one request; unrelated metadata tables are never loaded.
8
+ */
9
+ export function requestDatabase(DB, request, state={mutated:false}, binding=databaseBinding(DB)) {
10
+ if(!DB)return DB;
11
+ let revision,records;
12
+ const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
13
+ const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
14
+ const stamp=()=>revision??=(async()=>{
15
+ const query=DB.prepare("SELECT value FROM meta WHERE key='metadata-revision'");
16
+ if(recordQuery){const result=await DB.batch([query,DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined))]);records=result[1];return result[0].results?.[0]?.value??null;}
17
+ return (await query.first())?.value??null;
18
+ })();
19
+ const writing=sql=>{if(!/^\s*(SELECT|PRAGMA|EXPLAIN)\b/i.test(sql)){if(/\bapp_records\b/.test(sql)){records=null;revision=null;}if(/\b(office_games|servers|chat_rules)\b/.test(sql)){revision=null;metadata.delete(binding);}if(!/\bstats_daily\b/.test(sql))state.mutated=true;}};
20
+ const prepare=(sql,args=[])=>{
21
+ const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
22
+ const read=async()=>{
23
+ if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
24
+ if(!metadataQueries.has(sql))return original().all();
25
+ const version=await stamp();let cache=metadata.get(binding);
26
+ if(!cache||cache.version!==version){cache={version,rows:new Map()};metadata.set(binding,cache);}
27
+ if(version!==null&&cache.rows.has(sql))return cache.rows.get(sql);
28
+ const result=await original().all();if(version!==null)cache.rows.set(sql,result);return result;
29
+ };
30
+ return {bind:(...args)=>prepare(sql,args),first:async column=>{writing(sql);if(!metadataQueries.has(sql)&&sql!==recordQuery)return original().first(column);const row=(await read()).results?.[0]??null;return column&&row?row[column]:row;},all:()=>{writing(sql);return read();},run:()=>{writing(sql);return original().run();},raw:(...args)=>{writing(sql);return original().raw(...args);},_original:original,_sql:sql};
31
+ };
32
+ const methods={[ORIGINAL_DB]:binding,get [MUTATIONS](){return state.mutated;},prepare,batch:statements=>{for(const s of statements)writing(s._sql??'UPDATE');return DB.batch(statements.map(s=>s._original?.()??s));},exec:(sql,...args)=>{writing(sql);return DB.exec(sql,...args);},withSession:(...args)=>requestDatabase(DB.withSession(...args),request,state,binding)};
33
+ return new Proxy(methods,{get(target,key){if(key in target)return target[key];const value=DB[key];return typeof value==='function'?value.bind(DB):value;}});
34
+ }
35
+
36
+ /** A background task may register more work while it settles. */
37
+ export async function drainPending(pending) {
38
+ for(let done=0;done<pending.length;){const end=pending.length;await Promise.allSettled(pending.slice(done,end));done=end;}
39
+ }
40
+
41
+ /** Keep the runtime context intact and also deliver writes registered after the
42
+ * response's original drain finished (for example from response-stream work). */
43
+ export function trackBackground(ctx,deliver) {
44
+ const pending=[];let finished=false;
45
+ const context=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
46
+ if(key==='waitUntil')return work=>{
47
+ const task=Promise.resolve(work);pending.push(task);ctx.waitUntil(task);
48
+ if(finished)ctx.waitUntil(Promise.allSettled([task]).then(deliver));
49
+ };
50
+ const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
51
+ }}):ctx;
52
+ return {context,async finish(){await drainPending(pending);finished=true;await deliver();}};
53
+ }