@homie-rocks/studio 0.51.8 → 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 (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/docs/toolkit-place-patterns.md +6 -0
  5. package/functions/FUNCTIONS.md +27 -0
  6. package/functions/functions.ts +4 -0
  7. package/guides/app/PLACES.md +123 -0
  8. package/guides/app/SKILL.md +3 -0
  9. package/guides/game/SKILL.md +3 -0
  10. package/guides/shop/SKILL.md +27 -12
  11. package/lib/app-needs.mjs +41 -6
  12. package/lib/build.mjs +5 -0
  13. package/lib/check.mjs +6 -6
  14. package/lib/cloudflare.mjs +2 -2
  15. package/lib/dev-restart.mjs +25 -0
  16. package/lib/dev.mjs +6 -1
  17. package/lib/functions-build.mjs +3 -0
  18. package/lib/mcp-tools.mjs +2 -2
  19. package/lib/scaffold.mjs +22 -1
  20. package/lib/shop-links.mjs +4 -4
  21. package/lib/shop-mcp.mjs +82 -14
  22. package/lib/stripe-connect.mjs +9 -4
  23. package/lib/template-history.json +42 -0
  24. package/lib/upgrade-places.mjs +49 -0
  25. package/lib/upgrade.mjs +2 -1
  26. package/lib/worker-config.mjs +6 -3
  27. package/links/links.d.mts +5 -0
  28. package/links/links.d.ts +2 -0
  29. package/links/links.mjs +2 -0
  30. package/links/time.mjs +20 -0
  31. package/package.json +1 -1
  32. package/shop/SHOP.md +27 -12
  33. package/shop/shop.ts +1 -1
  34. package/tools/tools.ts +12 -0
  35. package/worker/app-records.mjs +5 -1
  36. package/worker/app-shell.mjs +27 -4
  37. package/worker/app-streams.mjs +57 -0
  38. package/worker/app-tools.mjs +44 -0
  39. package/worker/booking-holds.mjs +87 -0
  40. package/worker/build-id.mjs +1 -1
  41. package/worker/business-events.mjs +31 -0
  42. package/worker/event-resources.mjs +1 -1
  43. package/worker/functions.mjs +5 -0
  44. package/worker/index.mjs +19 -4
  45. package/worker/mcp-events.mjs +2 -2
  46. package/worker/mcp-tools.mjs +3 -0
  47. package/worker/mcp.mjs +3 -3
  48. package/worker/pages.mjs +4 -4
  49. package/worker/records-page.mjs +2 -2
  50. package/worker/shop-links.mjs +12 -0
  51. package/worker/shop.mjs +14 -4
  52. package/worker/version.mjs +1 -1
package/worker/pages.mjs CHANGED
@@ -122,7 +122,7 @@ export function sharePlaces(value) {
122
122
  * the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
123
123
  * covers the middle of the screen or a thumb.
124
124
  */
125
- 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 } = {}) {
126
126
  const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
127
127
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
128
128
  const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
@@ -214,7 +214,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
214
214
  // game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
215
215
  const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
216
216
  // The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
217
- 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 } : {}) };
218
218
  // game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
219
219
  const joinCorner = corner('join', 'bottom-right');
220
220
  const first = places.desk;
@@ -1173,10 +1173,10 @@ const OWNER_JS = String.raw`(function () {
1173
1173
  * watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
1174
1174
  * is the play door's). Every name a player typed is set with textContent.
1175
1175
  */
1176
- 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 } = {}) {
1177
1177
  const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
1178
1178
  const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
1179
- 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 } : {}) };
1180
1180
  const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
1181
1181
  return layoutless(`Watch ${g.name}`, `
1182
1182
  <header class="wtop" data-top>
@@ -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) {
8
+ export function recordsPage(meta,url,cat={},shop=null,test=false) {
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
- 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};
12
12
  const source=`/${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
  }
@@ -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.8';
2
+ export const STUDIO_VERSION_TAG = '0.52.0';