@homie-rocks/studio 0.51.4 → 0.51.6

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 (94) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +16 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/NEEDS.md +160 -0
  6. package/guides/app/SKILL.md +20 -4
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/parts/SKILL.md +1 -1
  18. package/guides/shop/SKILL.md +68 -77
  19. package/guides/sound/SKILL.md +70 -1
  20. package/guides/sound/scripts/player/sound.js +233 -0
  21. package/guides/studio-setup/SKILL.md +11 -27
  22. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  23. package/guides/style/SKILL.md +66 -3
  24. package/guides/video/SKILL.md +83 -0
  25. package/guides/video/references/DELIVERY.md +73 -0
  26. package/guides/video/references/HONESTY.md +40 -0
  27. package/guides/video/references/METHOD.md +144 -0
  28. package/guides/video/references/RECORD.md +137 -0
  29. package/guides/video/references/TRAILER.md +154 -0
  30. package/guides/video/references/examples/site-walkthrough.json +14 -0
  31. package/guides/video/references/examples/studio-play-phone.json +15 -0
  32. package/guides/video/references/examples/studio-play.json +17 -0
  33. package/guides/video/scripts/capture-game.mjs +175 -0
  34. package/guides/video/scripts/clock.js +136 -0
  35. package/guides/video/scripts/drawover/film.js +58 -0
  36. package/guides/video/scripts/drawover/index.html +9 -0
  37. package/guides/video/scripts/drawover/look.js +115 -0
  38. package/guides/video/scripts/drawover/shots.json +12 -0
  39. package/guides/video/scripts/lib/browser.mjs +87 -0
  40. package/guides/video/scripts/lib/frames.mjs +170 -0
  41. package/guides/video/scripts/lib/remix.mjs +159 -0
  42. package/guides/video/scripts/qa.mjs +127 -0
  43. package/guides/video/scripts/record-fixed.mjs +403 -0
  44. package/guides/video/scripts/record-page.mjs +477 -0
  45. package/guides/video/scripts/tap.js +108 -0
  46. package/guides/video/scripts/video.mjs +888 -0
  47. package/lib/app-needs.mjs +86 -18
  48. package/lib/app-ready.mjs +21 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/check.mjs +16 -7
  51. package/lib/cloudflare.mjs +6 -11
  52. package/lib/codex.mjs +7 -5
  53. package/lib/dev.mjs +1 -2
  54. package/lib/doctor.mjs +10 -54
  55. package/lib/mcp-tools.mjs +42 -21
  56. package/lib/mcp.mjs +1 -1
  57. package/lib/media-import.mjs +41 -0
  58. package/lib/media-workflows.mjs +13 -0
  59. package/lib/needs-headless.mjs +34 -0
  60. package/lib/official-media.mjs +15 -0
  61. package/lib/rules-build.mjs +13 -5
  62. package/lib/scaffold.mjs +2 -2
  63. package/lib/shop-links.mjs +81 -19
  64. package/lib/shop-listen.mjs +43 -0
  65. package/lib/shop-local.mjs +1 -1
  66. package/lib/shop-mcp.mjs +58 -0
  67. package/lib/stripe-connect.mjs +36 -15
  68. package/lib/template-history.json +87 -0
  69. package/lib/upgrade-media.mjs +13 -0
  70. package/lib/upgrade.mjs +10 -0
  71. package/package.json +1 -1
  72. package/parts/PARTS.md +1 -1
  73. package/rules/view.ts +4 -0
  74. package/shop/SHOP.md +110 -34
  75. package/worker/app-records.mjs +1 -0
  76. package/worker/build-id.mjs +1 -1
  77. package/worker/event-resources.mjs +9 -3
  78. package/worker/index.mjs +5 -1
  79. package/worker/local-test.mjs +4 -0
  80. package/worker/mcp-events.mjs +2 -2
  81. package/worker/mcp-tools.mjs +19 -1
  82. package/worker/mcp.mjs +1 -1
  83. package/worker/purchase-reconciliation.mjs +16 -1
  84. package/worker/shop-links.mjs +1 -1
  85. package/worker/shop-rules.mjs +3 -0
  86. package/worker/shop-subscriptions.mjs +2 -0
  87. package/worker/shop.mjs +58 -8
  88. package/worker/stripe-ownership.mjs +7 -0
  89. package/worker/version.mjs +1 -1
  90. package/guides/models/references/models.json +0 -175
  91. package/guides/music/scripts/lib/eleven.mjs +0 -260
  92. package/lib/projects-env.mjs +0 -68
  93. package/lib/projects.mjs +0 -311
  94. package/lib/provider-key.mjs +0 -21
@@ -14,6 +14,7 @@ export async function allowed(audience, caller, env) {
14
14
  if (audience === 'signed-in') return true;
15
15
  if (audience === 'owner') return caller.owner === true;
16
16
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
17
+ if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
17
18
  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());
18
19
  }
19
20
  return false;
@@ -86,7 +87,7 @@ export function builtins(env,cat,origin) {
86
87
  const {readableFeed,feedEvents,matchesFeed,eventFeeds}=await import('./event-resources.mjs');
87
88
  if(!await readableFeed(env,cat,ctx.caller,feed))throw new Error('Event feed unavailable');
88
89
  if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
89
- const page=await feedEvents(env,feed,{after});
90
+ const page=await feedEvents(env,feed,{after,caller:ctx.caller});
90
91
  return {kind:'untrusted-event-data',events:page.filter(e=>matchesFeed(eventFeeds(env)[feed],e)),next:page.length===100?page.at(-1).seq:null};
91
92
  }),
92
93
  tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
@@ -105,6 +106,23 @@ export function builtins(env,cat,origin) {
105
106
  const {replayFunction,runFunctions}=await import('./functions.mjs');
106
107
  await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
107
108
  }),
109
+ tool('studio_schedule_fire','Locally invoke a named scheduled function at a simulated timestamp.','owner',object({name:str,at:{type:'integer',minimum:0}},['name','at']),async({name,at},ctx)=>{
110
+ if(!env.HOMIE_LOCAL_TEST_SECRET || !['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local dev testing only');
111
+ const {syncFunctions,emitEvent,runFunctions}=await import('./functions.mjs');
112
+ const definitions=await syncFunctions(env), fn=definitions.find(f=>f.name===name&&f.schedule);
113
+ if(!fn)throw new Error(`Unknown scheduled function ${name}; declare it and rebuild dev`);
114
+ const data={function:name,cron:fn.schedule,scheduledTime:at,...(ctx.caller.testScope?{app:`${ctx.caller.testApp}:check:${ctx.caller.testScope}`}:{})};
115
+ const id=await emitEvent(env,fn.event,data);
116
+ await runFunctions(env,cat,origin);
117
+ let run;const end=Date.now()+10000;
118
+ do {
119
+ run=await env.DB.prepare('SELECT outcome,error FROM function_runs WHERE event=?1 AND function=?2 ORDER BY started DESC LIMIT 1').bind(id,name).first();
120
+ if(run && !['running','interrupted'].includes(run.outcome))break;
121
+ await new Promise(resolve=>setTimeout(resolve,50));
122
+ }while(Date.now()<end);
123
+ if(!run || run.outcome!=='complete')throw new Error(`Schedule ${name}: ${run?.error??run?.outcome??'no handler run'}`);
124
+ return {id,name,scheduledTime:at,simulated:true};
125
+ }),
108
126
  tool('studio_function_fire','Fire an event locally through the durable function dispatcher.','owner',object({event:str,id:str,data:{type:'object'}},['event']),async(args)=>{
109
127
  if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
110
128
  const {emitEvent,runFunctions}=await import('./functions.mjs');
package/worker/mcp.mjs CHANGED
@@ -82,7 +82,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
82
82
  const readPage=async(resourceUri,after=0)=>read('events:'+name,async fresh=>{
83
83
  if(!await readableFeed(env,cat,fresh,name))throw new Error('Event resource unavailable');
84
84
  if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
85
- const page=await feedEvents(env,name,{after});
85
+ const page=await feedEvents(env,name,{after,caller:fresh});
86
86
  const events=page.filter(e=>matchesFeed(eventFeeds(env)[name],e));
87
87
  return {contents:[{uri:String(resourceUri),mimeType:'application/json',text:JSON.stringify({kind:'untrusted-event-data',events,next:page.length===100?uri+'?after='+page.at(-1).seq:null})}]};
88
88
  });
@@ -1,3 +1,4 @@
1
+ import { stripeEventOwner } from './stripe-ownership.mjs';
1
2
  import { hasStripeKey, shopMode, rememberMoneyEvent, rememberedMoney } from './shop-links.mjs';
2
3
  import { paymentRefunds } from './shop-refunds.mjs';
3
4
  import { orderFact, bindHostedSession } from './purchase-core.mjs';
@@ -303,11 +304,25 @@ export async function reconcileOrder(env, o) {
303
304
 
304
305
  /** Called only AFTER the existing shop webhook signature/mode check. Return null for player orders. */
305
306
  export async function purchasePaymentEvent(env, event) {
307
+ const obj = event.data?.object ?? {};
308
+ const owner = stripeEventOwner(obj);
309
+ if (owner === 'shop') return null;
310
+ // Stripe can omit metadata on lifecycle events; an established shop subscription
311
+ // still belongs to the shop. Explicit ownership takes precedence over ID lookup.
312
+ if (!owner && (event.type.startsWith('invoice.') || event.type.startsWith('customer.subscription.'))) {
313
+ const subscription = event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
314
+ if (subscription) {
315
+ try {
316
+ if (await env.DB.prepare('SELECT 1 FROM shop_subscriptions WHERE id = ?1').bind(subscription).first()) return null;
317
+ } catch (error) {
318
+ if (!/no such table: (?:main\.)?shop_subscriptions\b/.test(String(error?.message))) throw error;
319
+ }
320
+ }
321
+ }
306
322
  // An existing player shop may receive webhooks before purchase migrations run.
307
323
  try { await env.DB.prepare("SELECT 1 FROM purchase_orders LIMIT 0").first(); }
308
324
  catch (error) { if (/no such table/.test(error.message)) return null; throw error; }
309
325
 
310
- const obj = event.data?.object ?? {};
311
326
  if (!event.type.startsWith('checkout.session.')) await rememberMoneyEvent(env,event);
312
327
  if (obj.payment_link && obj.metadata?.homie === 'purchase-links-v1') return reconcileLinkSession(env,obj);
313
328
  if (!hasStripeKey(env) && event.type.startsWith('customer.subscription.')) {
@@ -9,7 +9,7 @@ export function linkConfig(env) {
9
9
  export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
10
10
  export function linkSettings(shop) {
11
11
  return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
12
- cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
12
+ cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items, ...(shop.basket ? {basket:shop.basket} : {}) });
13
13
  }
14
14
  export function fullerReason(shop) {
15
15
  if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
@@ -209,7 +209,10 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
209
209
  const kids = audience === 'kids' && !policy.kidsStudio;
210
210
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
211
211
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
212
+ if (raw.basket !== undefined && (!isObj(raw.basket) || typeof raw.basket.name !== "string" || !raw.basket.name.trim() || raw.basket.name.length > 120 || typeof raw.basket.description !== "string" || raw.basket.description.length > 500)) errors.push({at:"basket",message:"A basket needs a name (1–120 characters) and description (up to 500 characters)."});
213
+ if (raw.basket && (raw.automaticTax || till !== "stripe")) errors.push({at:"basket",message:"Saved baskets use Stripe with tax already included in the menu prices."});
212
214
  const shop = {
215
+ ...(isObj(raw.basket) ? {basket:{name:raw.basket.name,description:raw.basket.description}} : {}),
213
216
  v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items,
214
217
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
215
218
  };
@@ -1,3 +1,4 @@
1
+ import { stripeEventOwner } from './stripe-ownership.mjs';
1
2
  import { linkConfig, hasStripeKey } from './shop-links.mjs';
2
3
  import { KEY_SHAPE, stripeCall } from './stripe.mjs';
3
4
  /** Only verified shop Checkout sessions establish ownership. Provider customer IDs
@@ -48,6 +49,7 @@ export async function bindSubscription(env, order, session, shop) {
48
49
  export async function subscriptionEvent(env, event, shop) {
49
50
  if (!['invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted'].includes(event.type)) return null;
50
51
  const obj=event.data.object;
52
+ if (stripeEventOwner(obj) === 'parts') return null;
51
53
  const subscription=event.type.startsWith('customer.subscription.') ? obj.id : idOf(obj.parent?.subscription_details?.subscription ?? obj.subscription);
52
54
  if (!subscription) return 'ignored';
53
55
  let row;
package/worker/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { stripeEventOwner } from './stripe-ownership.mjs';
1
2
  import { localSession } from './local-test.mjs';
2
3
  import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
3
4
  import { bookingStatement } from './booking-store.mjs';
@@ -162,10 +163,10 @@ export const MISSING_WORDS = Object.freeze({
162
163
  'shop.json-refused': 'shop.json conflicts with the studio settings or payment requirements (homie-studio shop check says which), so nothing is sold.',
163
164
  'till-off': 'shop.json says "till": "off".',
164
165
  items: 'shop.json has no items.',
165
- 'payment-links': 'Run homie-studio shop connect to sync your items and connect Stripe; approve its browser page if asked.',
166
- 'fuller-checkout': 'Your spending allowance or checkout expiry needs the fuller checkout connection. Ask your AI to connect it.',
167
- 'stripe-key': 'The Worker has no Stripe key yet (homie-studio shop connect: approve Stripe in your browser; the command names any remaining Worker credential step).',
168
- 'webhook-secret': 'The Worker has no webhook signing secret yet (run homie-studio shop connect to create and store it automatically).',
166
+ 'payment-links': 'Ask your AI to plan and verify this shop through Stripe’s official plugin, then install its private webhook (homie-studio shop plan, shop verify, shop webhook).',
167
+ 'fuller-checkout': 'These spending allowance, expiry or recurring amount settings are not supported by Payment Links. Ask your AI to review them with you before setup.',
168
+ 'stripe-key': 'This older checkout connection is unavailable. Ask your AI to set up Payment Links through Stripe’s official plugin.',
169
+ 'webhook-secret': 'Ask your AI to run homie-studio shop webhook after verifying the Stripe plugin setup; it installs the signing secret privately.',
169
170
  database: 'This Worker has no database.',
170
171
  migration: 'The shop\'s tables are not in the studio\'s D1 yet: npm run deploy applies migrations 0008_studio_shop.sql, 0010_shop_reservations.sql and 0012_shop_lines.sql.',
171
172
  });
@@ -516,7 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
516
517
  const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
517
518
  const config=linkConfig(env);
518
519
  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.')};
519
- const menu=keyless && !booking && lines.length>1 ? config?.menus?.find(m=>lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
520
+ 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;
520
521
  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;
521
522
  if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
522
523
  if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
@@ -532,7 +533,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
532
533
  }
533
534
  if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
534
535
  if(menu && menu.items.some(e=>{const i=shop.items.find(i=>i.id===e.item);return !i || i.game && i.game!==game;}))return {error:fail(409,'menu','This menu includes items from another app; sync separate menus for each app.')};
535
- if(menu)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
536
+ if(menu && !menu.basket)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
536
537
  if(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
537
538
  const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
538
539
  if (booking && reference === null) reference = order.id;
@@ -574,6 +575,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
574
575
  }
575
576
  if (keyless) {
576
577
  const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
578
+ if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
577
579
  return { order, url: checkout.href, reconciled };
578
580
  }
579
581
  let session = null;
@@ -832,6 +834,7 @@ async function hook(request, env, cat, shop, ready) {
832
834
  if (payment) await syncRefunds(env, payment);
833
835
  await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run(); return json({ ok: true, did: purchase }); }
834
836
  }
837
+ if (stripeEventOwner(obj) === 'parts') return json({ ok: true, did: 'ignored' });
835
838
  needed.push('payment_link.updated','invoice.paid','invoice.payment_failed','customer.subscription.updated','customer.subscription.deleted');
836
839
  if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
837
840
  if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
@@ -953,6 +956,7 @@ async function bindLinkSession(env, session, mode) {
953
956
  if (!source || source.mode !== mode) return false;
954
957
  const lines = await orderLines(env, source.id);
955
958
  if (!lines.length || !lines[0].snapshot) return false;
959
+ if(JSON.parse(lines[0].snapshot).paymentLink?.basket) return bindBasketSession(env,session,source,lines);
956
960
  if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
957
961
  const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
958
962
  const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
@@ -988,6 +992,50 @@ async function bindLinkSession(env, session, mode) {
988
992
  return true;
989
993
  }
990
994
 
995
+ /** A saved basket is immutable. A reopened link buys that same basket again. */
996
+ async function bindBasketSession(env,session,source,lines) {
997
+ const link=JSON.parse(lines[0].snapshot).paymentLink;
998
+ if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
999
+ if(session.payment_status!=='paid' || !Number.isSafeInteger(session.amount_total) || session.amount_total<=0 || session.amount_subtotal!==session.amount_total)return false;
1000
+ await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
1001
+ let order=await orderBySession(env,session.id);
1002
+ if(!order) {
1003
+ const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
1004
+ await env.DB.batch([
1005
+ env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,amount,currency,till,mode,'started',parent,?4,?4,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,Date.now()),
1006
+ env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2').bind(`shop-reference:${id}`,`shop-reference:${source.id}`),
1007
+ ...lines.map((l,i)=>env.DB.prepare('INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)').bind(`${id}_${i}`,id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
1008
+ ]);
1009
+ order=await orderBySession(env,session.id);
1010
+ }
1011
+ return Boolean(order);
1012
+ }
1013
+
1014
+ // Keep the actual payment in financial totals and the ordered amounts in lines.
1015
+ // Aggregate refunds are allocated proportionally; they do not change quantities.
1016
+ async function paidBasket(env,shop,o,lines,session,paidAt) {
1017
+ const total=session.amount_total;
1018
+ if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
1019
+ if(o.paid_at!==null)return 'already';
1020
+ const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
1021
+ if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
1022
+ const writes=[];let cumulative=0,allocated=0;
1023
+ for(const l of lines) {
1024
+ cumulative+=Number(l.amount);
1025
+ const next=Number(BigInt(total)*BigInt(cumulative)/BigInt(expected));
1026
+ const gross=next-allocated;allocated=next;
1027
+ if(total>=expected && o.player)writes.push(...grantStatements(env,o,JSON.parse(l.snapshot),paidAt,l));
1028
+ writes.push(env.DB.prepare("UPDATE shop_order_lines SET status='paid',total=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?3 AND paid_at IS NULL)").bind(l.id,gross,o.id));
1029
+ }
1030
+ const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
1031
+ writes.push(env.DB.prepare("UPDATE shop_orders SET status='paid',amount=?2,total=?2,tax=0,payment=?3,paid_at=?4,updated_at=?4,attention=0,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(o.id,total,payment??null,paidAt,total<expected?'Settle the difference at the bar':''));
1032
+ // Enrich the trigger's durable event in this same transaction, before dispatch.
1033
+ writes.push(env.DB.prepare("UPDATE studio_events SET data=json_set(data,'$.basketTotal',?2,'$.settlement',?3) WHERE id=?1").bind(`shop_orders:${o.id}:paid`,expected,total<expected?'short':'paid'));
1034
+ const result=await env.DB.batch(writes);
1035
+ if(payment)await syncRefunds(env,payment);
1036
+ return result.at(-2)?.meta?.changes?'paid':'already';
1037
+ }
1038
+
991
1039
  /** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
992
1040
  async function bindMenuSession(env,session,source,lines) {
993
1041
  const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
@@ -1027,7 +1075,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
1027
1075
  const o = await orderBySession(env, session.id);
1028
1076
  if (!o) return 'unknown-order';
1029
1077
  // The session must be the one this order opened, for this player and this price.
1030
- const linkOrder = (await orderLines(env, o.id)).some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
1078
+ const savedLines=await orderLines(env,o.id);
1079
+ if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
1080
+ const linkOrder = savedLines.some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
1031
1081
  if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
1032
1082
  if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
1033
1083
  if (['refunded', 'lost', 'disputed'].includes(o.status)) {
@@ -1194,7 +1244,7 @@ async function disputeClosed(env, payment, status) {
1194
1244
  }
1195
1245
 
1196
1246
  /** What the owner reads when Stripe holds a refund for approval (the shop's key is an Agent key). */
1197
- export const REFUND_HELD = 'Stripe is holding this refund until a person approves it in Stripe (Settings, Approvals, Requests): the shop\'s Stripe key is marked as an Agent key, and Stripe asks a person before an agent\'s refund. Approve it there; Stripe then refunds, and the item leaves the player\'s account when Stripe says so. To refund with one tap again, connect the shop with a plain restricted key (homie-studio shop connect), not one made for "Authorizing agent access".';
1247
+ export const REFUND_HELD = 'Stripe is holding this refund for your approval in Stripe (Settings, Approvals, Requests). Approve it there; the item leaves the player’s account after Stripe confirms the refund. You can also manage refunds through your connected Stripe AI; no credential change is needed.';
1198
1248
 
1199
1249
  /** Refund selected lines through Stripe, revoking each only after success. */
1200
1250
  export async function refundOrder(env, o, { reason = 'requested_by_customer', by = 'owner', line: lineId = null } = {}) {
@@ -0,0 +1,7 @@
1
+ /** Subscription metadata takes precedence over invoice-level metadata. */
2
+ export function stripeEventOwner(object) {
3
+ const metadata = object.parent?.subscription_details?.metadata ?? object.subscription_details?.metadata ?? object.metadata;
4
+ if (['shop-links-v1', 'shop-v1'].includes(metadata?.homie)) return 'shop';
5
+ if (['purchase-links-v1', 'purchase-v1'].includes(metadata?.homie)) return 'parts';
6
+ return null;
7
+ }
@@ -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.4';
2
+ export const STUDIO_VERSION_TAG = '0.51.6';
@@ -1,175 +0,0 @@
1
- {
2
- "v": 1,
3
- "checked": "2026-10-03",
4
- "note": "The endpoints the models skill uses, with the prices and schemas read live on the date above (fal's pricing API, its OpenAPI pages, the providers' terms). Endpoints retire and reprice monthly: `models.mjs registry` reads every price again for free and flags what moved; every receipt names the exact endpoint. Never hard-code one of these anywhere else.",
5
- "concept": {
6
- "withRefs": {
7
- "endpoint": "fal-ai/bytedance/seedream/v5/lite/edit",
8
- "price": {
9
- "usd": 0.035,
10
- "per": "image",
11
- "read": "2026-10-02"
12
- },
13
- "input": {
14
- "num_images": 1,
15
- "image_size": {
16
- "width": 2048,
17
- "height": 2048
18
- }
19
- },
20
- "refs": "image_urls",
21
- "maxRefs": 10,
22
- "why": "the cheapest multi-reference editor: the golden images keep every prop in one style"
23
- },
24
- "noRefs": {
25
- "endpoint": "fal-ai/bytedance/seedream/v5/lite/text-to-image",
26
- "price": {
27
- "usd": 0.035,
28
- "per": "image",
29
- "read": "2026-10-02"
30
- },
31
- "input": {
32
- "num_images": 1,
33
- "image_size": {
34
- "width": 2048,
35
- "height": 2048
36
- }
37
- },
38
- "why": "before there are golden images; the derived style prompt carries the look"
39
- },
40
- "minPixels": 3686400
41
- },
42
- "mesh": {
43
- "prop": {
44
- "endpoint": "tripo3d/p1/image-to-3d",
45
- "price": {
46
- "usd": 0.5,
47
- "per": "model with textures (0.40 without)",
48
- "read": "2026-10-02"
49
- },
50
- "credits": {
51
- "base": 40,
52
- "texture": 10,
53
- "defaults": {
54
- "texture": true
55
- }
56
- },
57
- "input": {
58
- "texture": true
59
- },
60
- "faceLimit": "face_limit",
61
- "output": "model_mesh",
62
- "why": "Tripo P1: built for low-poly game meshes, a face_limit that reaches a phone budget, $0.50 textured"
63
- },
64
- "fallbacks": [
65
- {
66
- "endpoint": "fal-ai/hyper3d/rodin/v2.5",
67
- "price": {
68
- "usd": 0.4,
69
- "per": "generation",
70
- "read": "2026-10-02"
71
- },
72
- "why": "quad presets and texture_delight; not wired by default"
73
- },
74
- {
75
- "endpoint": "fal-ai/trellis",
76
- "price": {
77
- "usd": 0.02,
78
- "per": "generation",
79
- "read": "2026-10-02"
80
- },
81
- "why": "drafts for exploring, then local simplify; not wired by default"
82
- }
83
- ]
84
- },
85
- "mood": {
86
- "endpoint": "fal-ai/bytedance/seedream/v5/lite/text-to-image",
87
- "price": {
88
- "usd": 0.035,
89
- "per": "image",
90
- "read": "2026-10-02"
91
- },
92
- "input": {
93
- "num_images": 1,
94
- "image_size": {
95
- "width": 2560,
96
- "height": 1440
97
- }
98
- }
99
- },
100
- "terms": [
101
- {
102
- "who": "fal",
103
- "url": "https://fal.ai/terms",
104
- "read": "2026-10-02",
105
- "lastUpdated": "2026-09-08",
106
- "says": "Customer owns its input; no express assignment of output; output not promised original or non-infringing; no training competing models on third-party outputs."
107
- },
108
- {
109
- "who": "Tripo (developer terms)",
110
- "url": "https://developers.tripo3d.ai/en/terms",
111
- "read": "2026-10-02",
112
- "lastUpdated": "2025-07-11",
113
- "says": "\"you may use Outputs for lawful commercial or non-commercial purposes\"."
114
- },
115
- {
116
- "who": "Meshy (through fal)",
117
- "url": "https://fal.ai/models/meshy/v7.1/image-to-3d",
118
- "read": "2026-10-03",
119
- "says": "fal labels the endpoint \"Commercial use\"; calls through fal are paid calls on the person's own fal account (fal's terms apply to the output). Meshy's own free plan (CC BY 4.0, Meshy owns output) does not apply: this skill never uses a Meshy free plan."
120
- }
121
- ],
122
- "character": {
123
- "mesh": {
124
- "endpoint": "meshy/v7.1/image-to-3d",
125
- "price": {
126
- "usd": 1.4,
127
- "per": "textured model with auto-rigging: US$0.80 base + US$0.40 textures + US$0.20 rigging (+US$0.12 an animation preset, not used)",
128
- "read": "2026-10-03",
129
- "api": "the pricing API says US$0.80 a generation (the base); the page's add-ons are added on top"
130
- },
131
- "addons": {
132
- "base": 0.8,
133
- "should_texture": 0.4,
134
- "enable_rigging": 0.2,
135
- "enable_animation": 0.12,
136
- "defaults": {
137
- "should_texture": true
138
- }
139
- },
140
- "input": {
141
- "model_type": "standard",
142
- "topology": "triangle",
143
- "target_polycount": 7000,
144
- "should_remesh": true,
145
- "should_texture": true,
146
- "enable_pbr": false,
147
- "pose_mode": "a-pose",
148
- "enable_rigging": true,
149
- "enable_animation": false,
150
- "symmetry_mode": "on"
151
- },
152
- "polycount": "target_polycount",
153
- "height": "rigging_height_meters",
154
- "output": "rigged_character_glb",
155
- "why": "Meshy 7.1 through fal: a textured mesh, a humanoid auto-rig and basic walk and run clips in one call, on the person's own fal account; the library's clips are retargeted onto its skeleton for free"
156
- },
157
- "rig": {
158
- "endpoint": "fal-ai/meshy/rigging",
159
- "price": {
160
- "usd": 0.8,
161
- "per": "request (the page says US$0.20; the pricing API says US$0.80: quoted at the higher until they agree)",
162
- "read": "2026-10-03"
163
- },
164
- "addons": {
165
- "base": 0.2,
166
- "enable_animation": 0.12
167
- },
168
- "input": {
169
- "enable_animation": false
170
- },
171
- "why": "rig a humanoid mesh made elsewhere (a Tripo prop-style mesh, the person's own); humanoids with clear limbs only; not wired by default"
172
- },
173
- "concept": "uses concept.withRefs or concept.noRefs: one full-body image in an A-pose, front view, plain background"
174
- }
175
- }