@homie-rocks/studio 0.51.7 → 0.52.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 (66) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/bin/homie-studio.mjs +9 -4
  5. package/docs/toolkit-place-patterns.md +68 -0
  6. package/functions/FUNCTIONS.md +27 -0
  7. package/functions/functions.ts +4 -0
  8. package/guides/app/PLACES.md +123 -0
  9. package/guides/app/SKILL.md +11 -0
  10. package/guides/app/examples/reserve.mjs +13 -0
  11. package/guides/functions/SKILL.md +2 -0
  12. package/guides/game/SKILL.md +3 -0
  13. package/guides/shop/SKILL.md +27 -12
  14. package/guides/studio-setup/SKILL.md +4 -0
  15. package/lib/app-needs.mjs +41 -6
  16. package/lib/app-ready.mjs +31 -0
  17. package/lib/build.mjs +5 -0
  18. package/lib/check.mjs +13 -10
  19. package/lib/cloudflare.mjs +2 -2
  20. package/lib/connector-token.mjs +30 -0
  21. package/lib/dev-restart.mjs +25 -0
  22. package/lib/dev.mjs +6 -1
  23. package/lib/functions-build.mjs +3 -0
  24. package/lib/mcp-tools.mjs +2 -2
  25. package/lib/scaffold.mjs +22 -1
  26. package/lib/shop-links.mjs +4 -4
  27. package/lib/shop-mcp.mjs +82 -14
  28. package/lib/stripe-connect.mjs +9 -4
  29. package/lib/template-history.json +84 -0
  30. package/lib/upgrade-places.mjs +49 -0
  31. package/lib/upgrade.mjs +2 -1
  32. package/lib/worker-config.mjs +6 -3
  33. package/links/links.d.mts +5 -0
  34. package/links/links.d.ts +2 -0
  35. package/links/links.mjs +2 -0
  36. package/links/time.mjs +20 -0
  37. package/netplay/netplay.ts +3 -0
  38. package/package.json +1 -1
  39. package/rules/collision.ts +4 -2
  40. package/rules/terrain.ts +2 -1
  41. package/rules/tiles.ts +8 -4
  42. package/shop/SHOP.md +27 -12
  43. package/shop/shop.ts +1 -1
  44. package/tools/tools.ts +12 -0
  45. package/worker/app-graphics.mjs +8 -0
  46. package/worker/app-records.mjs +5 -1
  47. package/worker/app-shell.mjs +61 -17
  48. package/worker/app-streams.mjs +57 -0
  49. package/worker/app-tools.mjs +44 -0
  50. package/worker/booking-holds.mjs +87 -0
  51. package/worker/build-id.mjs +1 -1
  52. package/worker/business-events.mjs +31 -0
  53. package/worker/connector-token.mjs +11 -0
  54. package/worker/event-resources.mjs +1 -1
  55. package/worker/functions.mjs +5 -0
  56. package/worker/index.mjs +24 -6
  57. package/worker/join-area.mjs +35 -0
  58. package/worker/mcp-events.mjs +2 -2
  59. package/worker/mcp-tools.mjs +3 -0
  60. package/worker/mcp.mjs +17 -9
  61. package/worker/pages.mjs +10 -7
  62. package/worker/records-page.mjs +5 -4
  63. package/worker/records-prefs.mjs +24 -0
  64. package/worker/shop-links.mjs +12 -0
  65. package/worker/shop.mjs +14 -4
  66. package/worker/version.mjs +1 -1
package/worker/mcp.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {tokenCaller,toolInScope} from './connector-token.mjs';
1
2
  import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
2
3
  import {eventsRequest} from './mcp-events.mjs';
3
4
  function freshErrorMessage(error,env,caller) {
@@ -38,10 +39,17 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
38
39
  if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
39
40
  const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
40
41
  const localIdentity=await localCaller(request,env);
42
+ const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
43
+ const bearerIdentity=bearer?await tokenCaller(request,env):null;
44
+ if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
41
45
  const api=async(request,env,ctx)=>{
42
- const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
46
+ const identity=async()=>{if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
43
47
  let caller;
44
48
  try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
49
+ if(caller.tools){
50
+ const body=request.method==='POST'?await request.clone().json().catch(()=>null):null;
51
+ if(request.method!=='POST'||!['initialize','notifications/initialized','ping','tools/list','tools/call'].includes(body?.method))return json({error:'connector is scoped to tools'},403);
52
+ }
45
53
  const cat=await catalogueOf();
46
54
  const tools=[...builtins(env,cat,origin),...definitions];
47
55
  await pushToolList(env,cat,tools,origin);
@@ -110,12 +118,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
110
118
  }
111
119
  }
112
120
  for(const tool of tools){
113
- if (!await allowed(tool.audience,caller,env)) continue;
121
+ if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
114
122
  if (tool.kind==='prompt') {
115
- if (!await allowed(tool.audience,caller,env)) continue;
123
+ if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
116
124
  s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
117
- if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
118
- const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
125
+ if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
126
+ const text=await tool.handler(args,services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool));
119
127
  return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
120
128
  }));continue;
121
129
  }
@@ -125,12 +133,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
125
133
  try{
126
134
  // Recheck inside dispatch, even when a connection listed tools earlier.
127
135
  const fresh=await identity();
128
- if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
136
+ if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
129
137
  try{await rate(fresh);}catch(error){outcome='limited';throw error;}
130
- const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
138
+ const context=services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool);
131
139
  if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
132
140
  if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
133
- const value=await tool.handler(args,{...context,request});
141
+ const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
134
142
  const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
135
143
  outcome='ok';return {content:[{type:'text',text}]};
136
144
  }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
@@ -163,7 +171,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
163
171
  };
164
172
  return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
165
173
  };
166
- if(localIdentity)return api(request,scoped,ctx);
174
+ if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
167
175
  const provider=new OAuthProvider({
168
176
  apiRoute:'/mcp',apiHandler:{fetch:api},
169
177
  authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
package/worker/pages.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {JOIN_AREA_SHELL_JS} from './join-area.mjs';
1
2
  import { appWords, openPath } from './app-format.mjs';
2
3
  import { APP_SHELL_JS } from './app-shell.mjs';
3
4
  /**
@@ -121,7 +122,7 @@ export function sharePlaces(value) {
121
122
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
122
123
  * covers the middle of the screen or a thumb.
123
124
  */
124
- export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
125
+ export function playPage(cat, g, { test = false, embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
125
126
  const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
126
127
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
127
128
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
@@ -142,11 +143,13 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
142
143
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
143
144
  .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
144
145
  .card ol { margin: 0; padding-left: 18px; }
145
- .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
146
+ :root{--homie-join-inset-top:0px;--homie-join-inset-right:0px;--homie-join-inset-bottom:0px;--homie-join-inset-left:0px;--homie-join-x:0px;--homie-join-y:0px;--homie-join-width:0px;--homie-join-height:0px}
147
+ .join { box-sizing:border-box;width:480px;max-width:calc(100vw - 32px);max-height:280px;overflow:hidden; position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
148
+ .join[hidden]{display:none}
146
149
  .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
147
150
  .join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
148
151
  .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
149
- .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
152
+ .join > div:last-child{min-width:0;overflow-wrap:anywhere}.join .qr { flex-shrink:0; width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
150
153
  .join .qr svg { width: 100%; height: 100%; display: block; }
151
154
  .join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
152
155
  .join span { opacity: .85; word-break: break-all; }
@@ -211,7 +214,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
211
214
  // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
212
215
  const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
213
216
  // The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
214
- const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
217
+ const boot = { ...(g.kind === 'app' ? { kind: 'app', test, appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
215
218
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
216
219
  const joinCorner = corner('join', 'bottom-right');
217
220
  const first = places.desk;
@@ -248,7 +251,7 @@ ${arrive.html}
248
251
  <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
249
252
  <div class="card" data-results hidden></div>
250
253
  <div class="card" data-screen hidden></div>
251
- ${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
254
+ ${joinCard}${joinCard ? `<script>${JOIN_AREA_SHELL_JS}</script>` : ''}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
252
255
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
253
256
  <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
254
257
  <script>${CHAT_JS}</script>
@@ -1170,10 +1173,10 @@ const OWNER_JS = String.raw`(function () {
1170
1173
  * watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
1171
1174
  * is the play door's). Every name a player typed is set with textContent.
1172
1175
  */
1173
- export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
1176
+ export function watchPage(cat, g, { test = false, origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
1174
1177
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
1175
1178
  const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
1176
- const boot = { ...(g.kind === 'app' ? { kind: 'app', screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
1179
+ const boot = { ...(g.kind === 'app' ? { kind: 'app', test, screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
1177
1180
  const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
1178
1181
  return layoutless(`Watch ${g.name}`, `
1179
1182
  <header class="wtop" data-top>
@@ -2,12 +2,13 @@ import {playerProperties} from './embed.mjs';
2
2
  import {appRole} from './app-format.mjs';
3
3
  import {esc} from './site.mjs';
4
4
  import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
5
+ import {RECORDS_PREFS_JS} from './records-prefs.mjs';
5
6
  import {APP_SHELL_JS} from './app-shell.mjs';
6
7
  /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
7
- export function recordsPage(meta,url,cat={},shop=null) {
8
- const params=new URLSearchParams(url.search);params.delete('access');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
8
+ export function recordsPage(meta,url,cat={},shop=null,test=false) {
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');
9
10
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
10
- const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
+ const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
12
  const source=`/${meta.id}/__game/?${params}`;
12
- 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>${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'}});
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'}});
13
14
  }
@@ -0,0 +1,24 @@
1
+ /** The records-only shell keeps net.prefs outside its opaque app frame. */
2
+ export const RECORDS_PREFS_JS=String.raw`(()=>{
3
+ const boot=window.__HOMIE_APP,frame=document.querySelector('iframe.game');
4
+ const storageKey='homie-prefs.'+boot.game;let memory=null;
5
+ function read(){if(memory)return memory;try{const v=JSON.parse(localStorage.getItem(storageKey)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{};}catch{return memory={};}}
6
+ addEventListener('message',e=>{
7
+ const m=e.data;if(e.source!==frame.contentWindow||m?.t!=='homie-net'||m.what!=='prefs')return;
8
+ const result={t:'homie-prefs',n:m.n,ok:false};
9
+ try{
10
+ const all=read(),key=m.k;
11
+ if(m.op==='all'){result.ok=true;result.all=all;}
12
+ else if(typeof key!=='string'||!key.length||key.length>64||key==='__proto__')result.why='key';
13
+ else if(m.op==='set'||m.op==='del'){
14
+ const next=Object.fromEntries(Object.entries(all).filter(([k])=>k!==key));
15
+ if(m.op==='set'&&m.v!=null)next[key]=m.v;
16
+ const text=JSON.stringify(next);
17
+ if(text.length>16384||Object.keys(next).length>32)result.why='too-large';
18
+ else if(typeof m.v==='number'&&!Number.isFinite(m.v))result.why='value';
19
+ else{result.ok=true;result.kept='kept';try{if(memory)throw Error('memory');localStorage.setItem(storageKey,text);}catch{memory=next;result.kept='memory';}}
20
+ }else result.why='op';
21
+ }catch{result.why='error';}
22
+ e.source.postMessage(result,'*');
23
+ });
24
+ })();`;
@@ -45,3 +45,15 @@ export async function rememberedMoney(env, payment, mode) {
45
45
  const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
46
46
  return rows.map((r) => JSON.parse(r.value));
47
47
  }
48
+
49
+ /** Asset configuration overrides legacy secrets, including an explicitly closed setup.
50
+ * Return a request-local env: never mutate shared bindings or cache across deployments. */
51
+ export async function loadShopLinks(env, origin='https://studio.invalid') {
52
+ if(!env.STRIPE_SHOP_LINKS_ASSET)return env;
53
+ let value;
54
+ try {
55
+ const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
56
+ if(response.ok)value=await response.json();
57
+ } catch {}
58
+ return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
59
+ }
package/worker/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
1
2
  import { stripeEventOwner } from './stripe-ownership.mjs';
2
3
  import { localSession } from './local-test.mjs';
3
4
  import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
@@ -154,7 +155,7 @@ export async function readiness(env, shop) {
154
155
  let migration = null;
155
156
  if (!env?.DB) missing.push('database');
156
157
  else if ((migration = await migrationNeeded(env))) missing.push('migration');
157
- return { ready: missing.length === 0, missing, next: missing.length ? MISSING_WORDS[missing[0]] : null, migration, mode: paidItems ? shopMode(env) : null };
158
+ return { ready: missing.length === 0, missing, next: missing.length ? (missing[0]==='payment-links' && env.STRIPE_SHOP_SETUP || MISSING_WORDS[missing[0]]) : null, migration, mode: paidItems ? shopMode(env) : null };
158
159
  }
159
160
 
160
161
  export const MISSING_WORDS = Object.freeze({
@@ -516,6 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
516
517
  lines ??= [{ item, amount, quantity: 1 }];
517
518
  const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
518
519
  const config=linkConfig(env);
520
+ if(booking?.hold&&keyless)return {error:fail(409,'booking','A held slot needs session checkout; keyless payment links cannot claim it.')};
519
521
  if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
520
522
  const menu=keyless && !booking && lines.length>0 ? config?.menus?.find(m=>(m.basket || lines.length>1)&&lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
521
523
  const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
@@ -546,7 +548,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
546
548
  lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
547
549
  const reserve = async () => {
548
550
  const results = await env.DB.batch([reservation(),
549
- ...(booking ? [bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
551
+ ...(booking ? [booking.hold?claimHoldStatement(env,{...booking,order:order.id}):bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
550
552
  ...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
551
553
  ...(menu ? [env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`shop-menu:${order.id}`,JSON.stringify(lines))] : []),
552
554
  ...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
@@ -558,7 +560,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
558
560
  await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
559
561
  let reserved;
560
562
  try { reserved = await reserve(); } catch (error) {
561
- if (/booking capacity unavailable/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
563
+ if (/booking capacity unavailable|booking hold changed or expired/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
562
564
  throw error;
563
565
  }
564
566
  const reconciled = !reserved.meta?.changes;
@@ -679,9 +681,17 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
679
681
  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.');
680
682
  const local = await localSession(request, env);
681
683
  booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
684
+ if(requestedBooking.hold!==undefined){
685
+ try {
686
+ const caller=local?.app===game?{id:`local-test-${local.app}-${local.role}`,testScope:local.scope}:{id:player.guest?null:player.id};
687
+ const hold=await ownedHold(env,caller,{id:requestedBooking.hold,token:requestedBooking.token});
688
+ if(hold.app!==booking.app||hold.collection!==collection||hold.slot!==booking.slot||hold.quantity!==booking.quantity||hold.order_id!=='hold:'+hold.id||!['held','confirmed'].includes(hold.state)||(hold.state==='held'&&hold.hold_until<=Date.now()))return fail(409,'booking','Hold changed or expired; reload.');
689
+ booking.hold=hold.id;
690
+ }catch{return fail(403,'booking','Booking owner required');}
691
+ }
682
692
  }
683
693
  const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
684
- const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: r.order.id } : {}) });
694
+ const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: booking.hold??r.order.id } : {}) });
685
695
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
686
696
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
687
697
  return response;
@@ -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.51.7';
2
+ export const STUDIO_VERSION_TAG = '0.52.0';