@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.
- package/CHANGELOG.md +49 -0
- package/README.md +10 -29
- package/apps/APPS.md +16 -1
- package/bin/homie-studio.mjs +28 -30
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +20 -4
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/parts/SKILL.md +1 -1
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/app-needs.mjs +86 -18
- package/lib/app-ready.mjs +21 -0
- package/lib/build.mjs +12 -9
- package/lib/check.mjs +16 -7
- package/lib/cloudflare.mjs +6 -11
- package/lib/codex.mjs +7 -5
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/needs-headless.mjs +34 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +81 -19
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +87 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +110 -34
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/index.mjs +5 -1
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +19 -1
- package/worker/mcp.mjs +1 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +58 -8
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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.')) {
|
package/worker/shop-links.mjs
CHANGED
|
@@ -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.';
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -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': '
|
|
166
|
-
'fuller-checkout': '
|
|
167
|
-
'stripe-key': '
|
|
168
|
-
'webhook-secret': '
|
|
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>
|
|
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
|
|
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
|
|
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
|
+
}
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
}
|