@homie-rocks/studio 0.53.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.
@@ -1,3 +1,4 @@
1
+ import {databaseBinding} from './request-db.mjs';
1
2
  import {appAccess} from './app-records.mjs';
2
3
  import {localSession} from './local-test.mjs';
3
4
  import {settingsOf,launchOf,accessOf} from './office.mjs';
@@ -10,15 +11,28 @@ END;`;}).join('\n')}
10
11
  `;
11
12
  const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
12
13
  export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
14
+ const pushedRevisions=new WeakMap();
13
15
  export async function pushAppRecords(env,cat){
14
- if(!env.LOBBY||!env.DB)return;
15
- await Promise.all((cat.games??[]).filter(g=>g.kind==='app').map(meta=>appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})}).catch(error=>console.error('App notification wake failed',error))));
16
+ const apps=(cat.games??[]).filter(g=>g.kind==='app');
17
+ if(!env.LOBBY||!env.DB||!apps.length)return;
18
+ const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
19
+ UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
20
+ const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
21
+ const binding=databaseBinding(env.DB),previous=pushedRevisions.get(binding)??new Map();
22
+ for(const meta of apps){
23
+ const signature=signatures.get(meta.id)??'';
24
+ if((previous.get(meta.id)??'')===signature)continue;
25
+ const response=await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})});
26
+ if(!response.ok)throw new Error('App notification wake failed');
27
+ previous.set(meta.id,signature);
28
+ }
29
+ pushedRevisions.set(binding,previous);
16
30
  }
17
31
  /** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
18
32
  export class AppRecordStreams {
19
33
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
20
34
  dispose(){for(const s of [...this.streams])this.close(s);}
21
- close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);try{s.controller.close();}catch{}this.presence();}
35
+ close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{}this.presence();}
22
36
  count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
23
37
  send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
24
38
  presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
@@ -28,7 +42,7 @@ export class AppRecordStreams {
28
42
  const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
29
43
  return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
30
44
  }
31
- async revisions(s){return (await this.env.DB.prepare('SELECT collection,revision FROM app_record_revisions WHERE app=?1').bind(s.app).all()).results;}
45
+ async revisions(s){return (await this.env.DB.prepare("SELECT collection,revision FROM app_record_revisions WHERE app=?1 UNION ALL SELECT '*',COALESCE(SUM(r.revision),0) FROM app_tool_namespaces a JOIN tool_data_revisions r ON r.namespace=a.namespace WHERE a.app=?1").bind(s.app).all()).results;}
32
46
  async open({meta,url,headers,collections=[]}){
33
47
  if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
34
48
  const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
@@ -40,17 +54,20 @@ export class AppRecordStreams {
40
54
  const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
41
55
  this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
42
56
  for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
57
+ // A fresh HTTP response renews the role cookie; SSE frames cannot set cookies.
58
+ s.reconnect=setTimeout(()=>this.close(s),240000);
43
59
  // Recovery after a missed wake; this is not the screen's refresh clock.
44
- s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
60
+ s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s,true)),15000);
45
61
  return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
46
62
  }
47
- async pump(s){
63
+ async pump(s,recheck=false){
48
64
  if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
49
65
  try{
66
+ const changed=new Set();
67
+ for(const row of await this.revisions(s)){if(s.seen.get(row.collection)!==row.revision){if(row.collection==='*')for(const c of s.collections)changed.add(c);else if(s.collections.includes(row.collection))changed.add(row.collection);}s.seen.set(row.collection,row.revision);}
68
+ if(!changed.size&&!recheck)return;
50
69
  if(!await this.authorized(s)){this.close(s);return;}
51
- const changed=[];
52
- for(const row of await this.revisions(s)){if(s.collections.includes(row.collection)&&s.seen.get(row.collection)!==row.revision)changed.push(row.collection);s.seen.set(row.collection,row.revision);}
53
- this.send(s,{collections:changed,presence:this.count(s)});
70
+ this.send(s,{collections:[...changed],presence:this.count(s)});
54
71
  }catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
55
72
  }
56
73
  async wake(meta){for(const s of this.streams)if(meta)s.meta=meta;await Promise.all([...this.streams].map(s=>this.pump(s)));return new Response('ok');}
@@ -37,6 +37,7 @@ export async function appToolsRoute(request,env,meta,url,cat,definitions){
37
37
  if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
38
38
  if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
39
39
  const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
40
+ await env.DB.prepare('INSERT OR IGNORE INTO app_tool_namespaces(app,namespace) VALUES(?1,?2)').bind(meta.id+(caller.testScope?':check:'+caller.testScope:''),namespace).run();
40
41
  const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
41
42
  outcome='ok';return Response.json({ok:true,data});
42
43
  }catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
@@ -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.53.0","sha256":"c43b8fcfbd61ff241c86555b2806cd599baf71513376d3958c379b04697dd923"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.54.0","sha256":"4f65918ccb0f52120d8d0fa6cf761f2243b4636fdf10ccef395479318d603370"});
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  /**
2
3
  * ROOM CHAT IN THE STUDIO'S OWN D1 (@homie-rocks/studio 0.23.0, NETPLAY.md section 19): the owner's chat rules per game
3
4
  * and per server, and the reports players make. Never the chat itself: what people say lives a few minutes in the
@@ -51,8 +52,8 @@ const cache = new WeakMap();
51
52
  /** Every chat rule row (D1), read at most every 5 s per Worker instance; empty before migration 0007. */
52
53
  export async function chatRowsOf(env, { fresh = false } = {}) {
53
54
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
54
- const hit = cache.get(env.DB);
55
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
55
+ const hit = cache.get(databaseBinding(env.DB));
56
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
56
57
  let map = new Map();
57
58
  try {
58
59
  const { results } = await env.DB.prepare('SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000').all();
@@ -62,10 +63,10 @@ export async function chatRowsOf(env, { fresh = false } = {}) {
62
63
  if (rules && typeof rules === 'object') map.set(`${r.game}/${r.server ?? ''}`, { ...rules, at: Number(r.updated_at) || 0 });
63
64
  }
64
65
  } catch { map = new Map(); }
65
- cache.set(env.DB, { at: Date.now(), map });
66
+ cache.set(databaseBinding(env.DB), { at: Date.now(), map });
66
67
  return map;
67
68
  }
68
- export const forgetChat = (env) => { if (env?.DB) cache.delete(env.DB); };
69
+ export const forgetChat = (env) => { if (env?.DB) cache.delete(databaseBinding(env.DB)); };
69
70
 
70
71
  /**
71
72
  * A room's chat rules: the game's game.json "chat", the owner's rules for the game, then for this server (a server's
@@ -1,3 +1,4 @@
1
+ import {databaseBinding} from './request-db.mjs';
1
2
  import {McpEventStreams} from './mcp-event-streams.mjs';
2
3
  import {listEvents} from './mcp-events.mjs';
3
4
  import {notification,sseFrame} from './mcp-events-wire.mjs';
@@ -141,13 +142,16 @@ export class EventStreams {
141
142
  }
142
143
  }
143
144
 
145
+ const toolCatalogues=new WeakMap();
144
146
  /** Refresh active streams whenever this Worker observes a new tool catalogue. */
145
147
  export async function pushToolList(env,cat,tools,origin) {
146
148
  if(!env.LOBBY)return;
147
149
  const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
148
150
  const signature=JSON.stringify([publicShape,eventFeeds(env)]);
151
+ const binding=databaseBinding(env.DB);
152
+ if(toolCatalogues.get(binding)===signature)return;
149
153
  const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
150
- if(previous?.value===signature)return;
154
+ if(previous?.value===signature){toolCatalogues.set(binding,signature);return;}
151
155
  await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
152
156
  let after='';
153
157
  for(;;){
@@ -155,4 +159,5 @@ export async function pushToolList(env,cat,tools,origin) {
155
159
  for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
156
160
  if(rows.length<100)break;after=rows.at(-1).key;
157
161
  }
162
+ toolCatalogues.set(binding,signature);
158
163
  }
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
- appAssetTicket, 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
 
@@ -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,13 @@ 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 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)}/">` : '';
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
+ }
543
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>`;
544
553
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
545
554
  return new Response(html, {
@@ -616,16 +625,10 @@ function finish(res, path) {
616
625
  return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
617
626
  }
618
627
 
619
- // Repeat after migration: an older isolate may recreate these during a rolling deploy.
620
- async function removeReferralLeftovers(env) {
621
- if(env.DB)await env.DB.prepare("DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*'").run();
622
- }
623
-
624
628
  export default {
625
629
  async scheduled(event, env, ctx) {
626
630
  env=await loadShopLinks(env);
627
631
  const maintenance=(async () => {
628
- await removeReferralLeftovers(env);
629
632
  const cat = await catalogue(env, 'https://studio.invalid');
630
633
  await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
631
634
  await reconcileOrders(env, shopOf(cat));
@@ -635,20 +638,19 @@ export default {
635
638
  await maintenance;
636
639
  },
637
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);
638
643
  env=await loadShopLinks(env,new URL(request.url).origin);
639
- await removeReferralLeftovers(env);
644
+ env={...env,DB:requestDatabase(env.DB,request)};
640
645
  const url = new URL(request.url);
641
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'}});
642
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'});
643
- await (await import('./functions.mjs')).syncFunctions(env);
644
648
  const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
645
649
  // Some routes reconcile shop state in waitUntil after returning their page.
646
650
  // Keep dispatch behind those writes too, preserving the runtime context's
647
651
  // methods and OAuth verification symbols through the proxy.
648
- const requestCtx=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
649
- if(key==='waitUntil')return work=>ctx.waitUntil(Promise.resolve(work).finally(dispatch));
650
- const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
651
- }}):ctx;
652
+ const background=trackBackground(ctx,()=>env.DB?.[MUTATIONS]?dispatch():undefined);
653
+ const requestCtx=background.context;
652
654
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
653
655
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
654
656
  const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
@@ -661,10 +663,16 @@ export default {
661
663
  res = json({ ok: false, error: 'failed' }, 500);
662
664
  } finally {
663
665
  // Refresh after the route has populated the catalogue cache.
664
- 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
+ }
665
673
  // A route can commit a write and then fail. Its outbox still needs delivery.
666
674
  if(env.DB) {
667
- const delivery=dispatch();
675
+ const delivery=background.finish();
668
676
  if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
669
677
  }
670
678
  }
@@ -683,6 +691,8 @@ export default {
683
691
  },
684
692
  };
685
693
 
694
+ // Refresh once per isolate/binding, and again after studio mutations. No shared pending I/O.
695
+ const toolCatalogues=new WeakSet();
686
696
  async function notifyTools(env,cat,origin) {
687
697
  if(!env.DB||!env.LOBBY)return;
688
698
  const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
@@ -950,8 +960,11 @@ async function route(request, env, ctx) {
950
960
  const meta = (all.games ?? []).find((g) => g.id === game);
951
961
  if (!meta) return fallback(request, env, url, getCat);
952
962
  if (parts.length === 1 && !path.endsWith('/')) return Response.redirect(`${url.origin}/${game}/`, 301);
953
- const cat = await getCat();
954
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();
955
968
  if (meta.kind === 'app' && /^open(?:\/(embed|preview))?$/.test(sub)) sub = sub.replace(/^open/, 'play');
956
969
  if (sub === '__restart') {
957
970
  if (env.HOMIE_PREVIEW !== '1' || request.method !== 'POST' || !hostedGame(game)) return new Response('not found', { status: 404 });
@@ -968,7 +981,7 @@ async function route(request, env, ctx) {
968
981
  if (meta.kind === 'app') {
969
982
  const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
970
983
  if(asset) {
971
- if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
984
+ if(!(await appAssetTicket(env,game,asset[1],{request,meta,launch})))return json({ok:false,error:'not-found'},404);
972
985
  const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
973
986
  response.headers.set('cache-control','private, no-store');
974
987
  response.headers.set('referrer-policy','no-referrer');
@@ -980,14 +993,18 @@ async function route(request, env, ctx) {
980
993
  const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
981
994
  if(web)return web;
982
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
+ };
983
1000
  if(sub==='api/app/live') {
984
1001
  if(request.method!=='GET')return new Response('GET required',{status:405});
985
1002
  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')})});
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')})}));
987
1004
  }
988
- 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()));
989
1006
  const api = await appRecordsRoute(request, env, meta, url, sub);
990
- if (api) return api;
1007
+ if (api) return sub.startsWith('api/app/records/')?apiSession(api):api;
991
1008
  if (['play', 'tv', 'watch'].includes(sub)) {
992
1009
  let a;
993
1010
  try { a = await appAccess(request, env, meta, url); } catch { return json({ ok: false, error: 'app roles unavailable; apply studio migrations' }, 503); }
@@ -995,7 +1012,7 @@ async function route(request, env, ctx) {
995
1012
  if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
996
1013
  return json({ ok: false, error: a.error }, a.status);
997
1014
  }
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);
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);
999
1016
  }
1000
1017
  }
1001
1018
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
@@ -1140,11 +1157,11 @@ async function route(request, env, ctx) {
1140
1157
  const sh = await availableShellShop(env, cat, game, { kids: pol.kids });
1141
1158
  let shopQr = null;
1142
1159
  if (sh && !local) try { shopQr = qrSvg(`${url.origin}/shop/?game=${encodeURIComponent(game)}`, { title: `Shop: ${meta.name ?? game}` }); } catch { shopQr = 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 });
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);
1144
1161
  }
1145
1162
  await countVisit(request, env, ctx, game, 'play');
1146
1163
  shareDaily(cat, url, ctx);
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 }) });
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);
1148
1165
  for(const cookie of recordsGuest?.cookies??[])page.headers.append('set-cookie',cookie);return page;
1149
1166
  }
1150
1167
  if (sub === 'watch') {
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);
@@ -1378,15 +1382,70 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1378
1382
  return null;
1379
1383
  }
1380
1384
 
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)}`;
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;
1387
1421
  }
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]));
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}`)}`;
1390
1449
  }
1391
1450
 
1392
1451
  /** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
@@ -1404,3 +1463,19 @@ export async function checkSessionTicket(env, data, token) {
1404
1463
  return session;
1405
1464
  }catch{return null;}
1406
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 };
@@ -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
+ }
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  /**
2
3
  * SERVERS (@homie-rocks/studio 0.16.0; NETPLAY.md section 17): a named, lasting pool of rooms for one game, with its
3
4
  * own policy, its own door and its own Lobby pool. Strangers are matched only inside one server, never across.
@@ -171,8 +172,8 @@ const serverCache = new WeakMap();
171
172
  /** Every server row of every game (D1), read at most every 5 s per Worker instance; an empty map before migration 0006. */
172
173
  async function rowsOf(env, { fresh = false } = {}) {
173
174
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
174
- const hit = serverCache.get(env.DB);
175
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
175
+ const hit = serverCache.get(databaseBinding(env.DB));
176
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
176
177
  let map = new Map();
177
178
  try {
178
179
  const { results } = await env.DB.prepare('SELECT * FROM servers LIMIT 2000').all();
@@ -182,10 +183,10 @@ async function rowsOf(env, { fresh = false } = {}) {
182
183
  map.set(r.game, list);
183
184
  }
184
185
  } catch { map = new Map(); }
185
- serverCache.set(env.DB, { at: Date.now(), map });
186
+ serverCache.set(databaseBinding(env.DB), { at: Date.now(), map });
186
187
  return map;
187
188
  }
188
- export const forgetServers = (env) => { if (env?.DB) serverCache.delete(env.DB); };
189
+ export const forgetServers = (env) => { if (env?.DB) serverCache.delete(databaseBinding(env.DB)); };
189
190
 
190
191
  /**
191
192
  * A game's servers: `public` first, then the rest by when they were made. The game.json seeds (the catalogue's