@homie-rocks/studio 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +8 -2
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/lib/app-needs.mjs +19 -10
  9. package/lib/app-ready.mjs +1 -1
  10. package/lib/build.mjs +3 -2
  11. package/lib/check.mjs +37 -13
  12. package/lib/dev.mjs +1 -0
  13. package/lib/fork.mjs +47 -0
  14. package/lib/jobs.mjs +1 -0
  15. package/lib/mcp-tools.mjs +1 -1
  16. package/lib/needs-headless.mjs +4 -3
  17. package/lib/scaffold.mjs +8 -1
  18. package/lib/shop-links.mjs +3 -3
  19. package/lib/shop-mcp.mjs +6 -0
  20. package/lib/stripe-connect.mjs +37 -2
  21. package/lib/template-history.json +42 -0
  22. package/lib/tools-cli.mjs +25 -3
  23. package/lib/worker-config.mjs +1 -0
  24. package/package.json +1 -1
  25. package/rules/rules.ts +1 -1
  26. package/shop/shop.ts +3 -3
  27. package/tools/tools.ts +3 -2
  28. package/worker/app-format.mjs +6 -3
  29. package/worker/app-records.mjs +16 -4
  30. package/worker/app-shell.mjs +1 -1
  31. package/worker/app-tools.mjs +1 -1
  32. package/worker/app-web.mjs +23 -0
  33. package/worker/booking-calendar.mjs +26 -0
  34. package/worker/booking-holds.mjs +18 -5
  35. package/worker/booking-options.mjs +23 -0
  36. package/worker/booking-resources.mjs +55 -0
  37. package/worker/build-id.mjs +1 -1
  38. package/worker/customer-mcp.mjs +1 -1
  39. package/worker/index.mjs +36 -6
  40. package/worker/local-test.mjs +10 -0
  41. package/worker/mcp-events.mjs +2 -2
  42. package/worker/mcp-tools.mjs +20 -2
  43. package/worker/office.mjs +27 -0
  44. package/worker/records-page.mjs +2 -2
  45. package/worker/shop.mjs +6 -1
  46. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -123,7 +123,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
123
123
  import { ownerRoutes } from './stats-page.mjs';
124
124
  import { playerRoutes, players as playerAccounts } from './players.mjs';
125
125
  import {
126
- accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
126
+ appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
127
127
  ticketFor, ticketSub, usePlayers, verifyControl,
128
128
  } from './office.mjs';
129
129
 
@@ -154,7 +154,7 @@ export { mergeSaves } from './saves.mjs';
154
154
  const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
155
155
  const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
156
156
  /** 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(?:\/|$)/;
157
+ const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
158
158
  const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
159
159
  /** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
160
160
  const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
@@ -539,7 +539,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
539
539
  };
540
540
  let html = await res.text();
541
541
  const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
542
- const head = `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
542
+ const assetBase = meta.kind === 'app' && !url.pathname.includes('/__assets/') && launchOf(meta, await settingsOf(env, {fresh:true}), env) !== 'public' ? `<base href="/${game}/__assets/${await appAssetTicket(env,game)}/">` : '';
543
+ const head = assetBase + `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
543
544
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
544
545
  return new Response(html, {
545
546
  headers: {
@@ -637,6 +638,8 @@ export default {
637
638
  env=await loadShopLinks(env,new URL(request.url).origin);
638
639
  await removeReferralLeftovers(env);
639
640
  const url = new URL(request.url);
641
+ if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
642
+ if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
640
643
  await (await import('./functions.mjs')).syncFunctions(env);
641
644
  const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
642
645
  // Some routes reconcile shop state in waitUntil after returning their page.
@@ -665,6 +668,16 @@ export default {
665
668
  if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
666
669
  }
667
670
  }
671
+ if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
672
+ const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
673
+ let body=res.body;
674
+ if(/text\/html/i.test(headers.get('content-type')??'')) {
675
+ const html=await res.text(),banner='<aside role="status" style="position:fixed;z-index:2147483647;top:0;left:0;right:0;text-align:center;background:#ffda44;color:#171717;padding:6px;font:bold 14px system-ui;pointer-events:none">TEST / CONCEPT</aside>';
676
+ body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
677
+ headers.delete('content-length');
678
+ }
679
+ res=new Response(body,{status:res.status,headers});
680
+ }
668
681
  if (appLobby || appRecords) return appCors(request, res);
669
682
  return res;
670
683
  },
@@ -842,7 +855,7 @@ async function route(request, env, ctx) {
842
855
  if (own.ok) return own;
843
856
  const cat = await getCat();
844
857
  const method = request.method;
845
- if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
858
+ if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
846
859
  const all = await getAll();
847
860
  if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
848
861
  const full = path === '/llms-full.txt';
@@ -927,6 +940,10 @@ async function route(request, env, ctx) {
927
940
  return postPage(cat, post, { origin: url.origin });
928
941
  }
929
942
 
943
+ if(parts[0]==='games' && parts.length>2) {
944
+ const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
945
+ if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
946
+ }
930
947
  const game = parts[0];
931
948
  if (game && GAME_ID.test(game)) {
932
949
  const all = await getAll();
@@ -949,7 +966,20 @@ async function route(request, env, ctx) {
949
966
  const launch = launchOf(meta, settings, env);
950
967
  const max = seatsFor(meta, settings);
951
968
  if (meta.kind === 'app') {
952
- if (launch !== 'public' && !(await accessOf(request, env, game, launch)).ok) return json({ ok: false, error: 'not-found' }, 404);
969
+ const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
970
+ if(asset) {
971
+ if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
972
+ const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
973
+ response.headers.set('cache-control','private, no-store');
974
+ response.headers.set('referrer-policy','no-referrer');
975
+ return response;
976
+ }
977
+ const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
978
+ if (launch !== 'public' && !frameTicket && !(await accessOf(request, env, game, launch)).ok && (await (await import('./local-test.mjs')).localSession(request,env))?.app!==game) return json({ ok: false, error: 'not-found' }, 404);
979
+ if(launch==='public') {
980
+ const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
981
+ if(web)return web;
982
+ }
953
983
  if(sub==='api/app/live') {
954
984
  if(request.method!=='GET')return new Response('GET required',{status:405});
955
985
  const {appHub}=await import('./app-streams.mjs');
@@ -965,7 +995,7 @@ async function route(request, env, ctx) {
965
995
  if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
966
996
  return json({ ok: false, error: a.error }, a.status);
967
997
  }
968
- if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)));
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);
969
999
  }
970
1000
  }
971
1001
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
@@ -1,6 +1,8 @@
1
1
  /** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
2
2
  const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
3
3
  export async function localCaller(request, env) {
4
+ const remote=await remoteSession(request,env);
5
+ if(remote)return {id:`check-${remote.app}-${remote.role}`,owner:false,client:null,localTest:true,remoteTest:true,roles:{[remote.app]:[remote.role]},testApp:remote.app,testScope:remote.scope};
4
6
  if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
5
7
  const session=await localSession(request,env);
6
8
  // A supplied but invalid signed session must never fall back to owner.
@@ -9,6 +11,7 @@ export async function localCaller(request, env) {
9
11
  return {id:'local-test-owner',owner:true,client:null};
10
12
  }
11
13
  export async function localSession(request, env) {
14
+ const remote=await remoteSession(request,env);if(remote)return remote;
12
15
  if (!local(request, env)) return null;
13
16
  const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
14
17
  if (!token) return null;
@@ -21,3 +24,10 @@ export async function localSession(request, env) {
21
24
  return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
22
25
  } catch { return null; }
23
26
  }
27
+
28
+ async function remoteSession(request,env) {
29
+ const token=/(?:^|;\s*)homie_check=([^;]+)/.exec(request.headers.get('cookie')??'')?.[1];
30
+ if(!token)return null;
31
+ const session=await (await import('./office.mjs')).checkSessionTicket(env,null,token);
32
+ return session?.origin===new URL(request.url).origin?{...session,remoteTest:true}:null;
33
+ }
@@ -41,9 +41,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
41
41
  const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
42
42
  WHERE type=?1 AND seq>?2 AND seq<=?3 AND scope IS ?7
43
43
  AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
44
- ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1,feed.event==='record.changed'?null:caller.testScope??null).all()).results;
44
+ ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1,feed.event==='record.changed'?null:caller.testScope??null).all()).results;
45
45
  const hasMore=rows.length>max,page=rows.slice(0,max);
46
- const events=page.map(row=>{const data=JSON.parse(row.data);if(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
46
+ const events=page.map(row=>{const data=JSON.parse(row.data);if((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
47
47
  const after=hasMore?page.at(-1).seq:start.through;
48
48
  return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
49
49
  }
@@ -1,3 +1,4 @@
1
+ import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
1
2
  import {bookingHolds} from './booking-holds.mjs';
2
3
  import {toolDatabase} from './tool-database.mjs';
3
4
  import { selling } from './extensions.mjs';
@@ -16,7 +17,7 @@ export async function allowed(audience, caller, env) {
16
17
  if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
17
18
  if (audience === 'owner') return caller.owner === true;
18
19
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
19
- if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
20
+ if((env.HOMIE_LOCAL_TEST_SECRET || caller.remoteTest) && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
20
21
  return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
21
22
  }
22
23
  return false;
@@ -38,9 +39,11 @@ export async function currentCaller(env, props) {
38
39
  }
39
40
  export function services(env, cat, origin, caller, namespace, definition = {}) {
40
41
  const records = async ({app,role,collection,id,operation='read',data,version}) => {
42
+ if(operation!=='read'&&!id)throw Object.assign(new Error('Put id at the top level of the arguments, alongside app and collection (1–64 letters, digits, underscore or hyphen).'),{name:'StudioOperationError'});
43
+ if(caller.remoteTest&&app!==caller.testApp)throw new Error('Test session belongs to another app');
41
44
  const meta = cat.games.find(g=>g.id===app && g.kind==='app');
42
45
  if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw Object.assign(new Error('Unknown app or collection; collection names start with a lowercase letter and use lowercase letters, digits and hyphens (1–40). Record id: 1–64 letters, digits, underscore or hyphen.'),{name:'StudioOperationError'});
43
- if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
46
+ if (!caller.owner && !caller.remoteTest && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
44
47
  if(!role) {
45
48
  const candidates=[];
46
49
  for(const [name,declared] of Object.entries(meta.roles??{})) {
@@ -90,6 +93,13 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
90
93
  export function builtins(env,cat,origin) {
91
94
  const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
92
95
  const tools=[
96
+ tool('studio_check_session','Owner: create a short-lived app-role test session with isolated records. Never grants global owner access.','owner',object({app:str,role:str,scope:{type:'string',pattern:'^check-[a-zA-Z0-9-]{1,64}$'}},['app','role','scope']),async(args,ctx)=>{
97
+ if(!ctx.caller.owner)throw new Error('Owner required');
98
+ const meta=cat.games.find(g=>g.id===args.app&&g.kind==='app');
99
+ if(!meta?.roles?.[args.role])throw new Error('Name a declared app role');
100
+ const token=await (await import('./office.mjs')).checkSessionTicket(env,{...args,origin});
101
+ return {token,expiresIn:900};
102
+ }),
93
103
  tool('studio_events_clear','Owner: permanently clear retained events and deliveries through a sequence number. Newer events remain.','owner',object({through:{type:'integer',minimum:0}},['through']),async({through},ctx)=>{
94
104
  if(!ctx.caller.owner)throw new Error('Owner only');
95
105
  if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
@@ -166,6 +176,14 @@ export function builtins(env,cat,origin) {
166
176
  const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
167
177
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
168
178
  }),
179
+ tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
180
+ const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
181
+ if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
182
+ const minutes=Number.isSafeInteger(spec.holdMinutes)&&spec.holdMinutes>0?Math.min(spec.holdMinutes,1440):15;
183
+ if(!Number.isSafeInteger(args.expiresAt)||args.expiresAt<=Date.now()||args.expiresAt>Date.now()+minutes*60000)throw Object.assign(new Error(`Hold expiry must be within ${minutes} minutes`),{name:'StudioOperationError'});
184
+ await ctx.records({app:args.app,collection:spec.slots,id:args.slot});
185
+ return ctx.bookings.hold(args);
186
+ }),
169
187
  tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'1–64 letters, digits, underscore or hyphen'},operation:{enum:['read','create','update','delete']},data:{type:'object',propertyNames:{pattern:'^[a-z][a-z0-9_]*$'},description:'Field names start with a lowercase letter and use lowercase letters, digits and underscore; discover fields with app_schema.'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
170
188
  ];
171
189
  for(const [name,path,description] of [
package/worker/office.mjs CHANGED
@@ -1377,3 +1377,30 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1377
1377
  }
1378
1378
  return null;
1379
1379
  }
1380
+
1381
+ /** Asset-only capability: cannot authenticate records, tools or room sockets. */
1382
+ export async function appAssetTicket(env, app, ticket) {
1383
+ const key=await officeKey(env);if(!key)return null;
1384
+ if(ticket===undefined) {
1385
+ const exp=(Date.now()+5*60_000).toString(36);
1386
+ return `${exp}.${(await mac(key,`app-assets|${app}|${exp}`)).slice(0,32)}`;
1387
+ }
1388
+ const m=/^([a-z0-9]+)\.([A-Za-z0-9_-]{32})$/.exec(ticket);
1389
+ return Boolean(m && parseInt(m[1],36)>Date.now() && same((await mac(key,`app-assets|${app}|${m[1]}`)).slice(0,32),m[2]));
1390
+ }
1391
+
1392
+ /** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
1393
+ export async function checkSessionTicket(env, data, token) {
1394
+ const key=await officeKey(env);if(!key)return null;
1395
+ if(token===undefined) {
1396
+ const payload=b64url(new TextEncoder().encode(JSON.stringify({...data,expires:Date.now()+15*60_000})));
1397
+ return payload+'.'+await mac(key,'check-session|'+payload);
1398
+ }
1399
+ try {
1400
+ const [payload,signature,...extra]=token.split('.');
1401
+ if(extra.length||!signature||!same(await mac(key,'check-session|'+payload),signature))return null;
1402
+ const session=JSON.parse(atob(payload.replaceAll('-','+').replaceAll('_','/')));
1403
+ if(session.expires<=Date.now()||!Number.isSafeInteger(session.expires)||!/^check-[a-zA-Z0-9-]{1,64}$/.test(session.scope)||!GAME_ID.test(session.app)||!/^[a-z][a-z0-9-]{0,39}$/.test(session.role))return null;
1404
+ return session;
1405
+ }catch{return null;}
1406
+ }
@@ -5,10 +5,10 @@ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
5
5
  import {RECORDS_PREFS_JS} from './records-prefs.mjs';
6
6
  import {APP_SHELL_JS} from './app-shell.mjs';
7
7
  /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
8
- export function recordsPage(meta,url,cat={},shop=null,test=false) {
8
+ export function recordsPage(meta,url,cat={},shop=null,test=false,assetTicket=null) {
9
9
  const params=new URLSearchParams(url.search);params.delete('access');params.set('pf','1');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
10
10
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
11
11
  const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
12
- const source=`/${meta.id}/__game/?${params}`;
12
+ const source=assetTicket?`/${meta.id}/__assets/${assetTicket}/index.html?${params}`:`/${meta.id}/__game/?${params}`;
13
13
  return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${RECORDS_PREFS_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
14
14
  }
package/worker/shop.mjs CHANGED
@@ -577,7 +577,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
577
577
  }
578
578
  if (keyless) {
579
579
  const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
580
- if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
580
+ if(menu?.basket || item.kind==='deposit')checkout.searchParams.set('prefilled_amount',String(amount));
581
581
  return { order, url: checkout.href, reconciled };
582
582
  }
583
583
  let session = null;
@@ -650,6 +650,8 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
650
650
  if (!item) return fail(404, 'item', 'no such item here');
651
651
  const quantity = entry.quantity ?? 1;
652
652
  if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
653
+ if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
654
+ if(item.kind==='deposit' && (!reference || !Number.isSafeInteger(entry.amount??item.price) || (entry.amount??item.price)<=0))return fail(400,'deposit','A deposit requires the app reference and its saved positive amount.');
653
655
  if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
654
656
  seen.add(item.id);
655
657
  const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
@@ -679,6 +681,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
679
681
  const collection = requestedBooking?.collection;
680
682
  const spec = app?.records?.collections?.[collection]?.booking;
681
683
  if (!spec || !/^[A-Za-z0-9_-]{1,64}$/.test(requestedBooking?.slot ?? '') || lines.length !== 1 || item.id !== spec.item || item.interval) return fail(400, 'booking', 'Choose a declared booking slot and its service item.');
684
+ if(spec.resources&&requestedBooking.hold===undefined)return fail(409,'booking','Reserve the resource through the booking tool and pass its hold before checkout.');
682
685
  const local = await localSession(request, env);
683
686
  booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
684
687
  if(requestedBooking.hold!==undefined){
@@ -976,7 +979,9 @@ async function bindLinkSession(env, session, mode) {
976
979
  if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
977
980
  const amount = session.amount_subtotal;
978
981
  if (!Number.isSafeInteger(amount) || amount <= 0) return false;
982
+ if(item.kind==='deposit' && amount!==source.amount)return false;
979
983
  const quantity = item.price === 'choose' ? 1 : amount / item.price;
984
+ if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
980
985
  if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
981
986
  if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
982
987
  // Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.52.0';
2
+ export const STUDIO_VERSION_TAG = '0.53.0';