@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.
- package/CHANGELOG.md +57 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +9 -3
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +27 -11
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +4 -3
- package/lib/check.mjs +47 -15
- package/lib/dev.mjs +9 -1
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +27 -1
- package/lib/shop-links.mjs +39 -21
- package/lib/shop-mcp.mjs +13 -1
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +20 -6
- package/worker/app-shell.mjs +10 -5
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +2 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/customer-mcp.mjs +1 -1
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +74 -27
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +108 -6
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/records-page.mjs +2 -2
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +68 -8
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
644
|
+
env={...env,DB:requestDatabase(env.DB,request)};
|
|
639
645
|
const url = new URL(request.url);
|
|
640
|
-
|
|
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
|
|
646
|
-
|
|
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)
|
|
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=
|
|
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
|
-
|
|
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') {
|
package/worker/local-test.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
2
|
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
3
|
export async function localCaller(request, env) {
|
|
4
|
+
const remote=await remoteSession(request,env);
|
|
5
|
+
if(remote)return {id:`check-${remote.app}-${remote.role}`,owner:false,client:null,localTest:true,remoteTest:true,roles:{[remote.app]:[remote.role]},testApp:remote.app,testScope:remote.scope};
|
|
4
6
|
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
7
|
const session=await localSession(request,env);
|
|
6
8
|
// A supplied but invalid signed session must never fall back to owner.
|
|
@@ -9,6 +11,7 @@ export async function localCaller(request, env) {
|
|
|
9
11
|
return {id:'local-test-owner',owner:true,client:null};
|
|
10
12
|
}
|
|
11
13
|
export async function localSession(request, env) {
|
|
14
|
+
const remote=await remoteSession(request,env);if(remote)return remote;
|
|
12
15
|
if (!local(request, env)) return null;
|
|
13
16
|
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
14
17
|
if (!token) return null;
|
|
@@ -21,3 +24,10 @@ export async function localSession(request, env) {
|
|
|
21
24
|
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
22
25
|
} catch { return null; }
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
async function remoteSession(request,env) {
|
|
29
|
+
const token=/(?:^|;\s*)homie_check=([^;]+)/.exec(request.headers.get('cookie')??'')?.[1];
|
|
30
|
+
if(!token)return null;
|
|
31
|
+
const session=await (await import('./office.mjs')).checkSessionTicket(env,null,token);
|
|
32
|
+
return session?.origin===new URL(request.url).origin?{...session,remoteTest:true}:null;
|
|
33
|
+
}
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -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
|
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/worker/records-page.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
|
5
5
|
import {RECORDS_PREFS_JS} from './records-prefs.mjs';
|
|
6
6
|
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
7
7
|
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
8
|
-
export function recordsPage(meta,url,cat={},shop=null,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
|
|
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
|
+
}
|