@homie-rocks/studio 0.51.4 → 0.51.5

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 (80) hide show
  1. package/CHANGELOG.md +29 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +1 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/SKILL.md +1 -1
  6. package/guides/art/SKILL.md +68 -140
  7. package/guides/art/scripts/art.mjs +6 -79
  8. package/guides/models/SKILL.md +73 -177
  9. package/guides/models/references/RIGHTS.md +1 -1
  10. package/guides/models/scripts/models.mjs +7 -288
  11. package/guides/music/SKILL.md +74 -183
  12. package/guides/music/references/COMPOSITION.md +21 -26
  13. package/guides/music/references/GAME-AUDIO.md +3 -3
  14. package/guides/music/references/RIGHTS.md +12 -15
  15. package/guides/music/scripts/music.mjs +11 -202
  16. package/guides/parts/SKILL.md +1 -1
  17. package/guides/shop/SKILL.md +68 -77
  18. package/guides/sound/SKILL.md +70 -1
  19. package/guides/sound/scripts/player/sound.js +233 -0
  20. package/guides/studio-setup/SKILL.md +11 -27
  21. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  22. package/guides/style/SKILL.md +66 -3
  23. package/guides/video/SKILL.md +83 -0
  24. package/guides/video/references/DELIVERY.md +73 -0
  25. package/guides/video/references/HONESTY.md +40 -0
  26. package/guides/video/references/METHOD.md +144 -0
  27. package/guides/video/references/RECORD.md +137 -0
  28. package/guides/video/references/TRAILER.md +154 -0
  29. package/guides/video/references/examples/site-walkthrough.json +14 -0
  30. package/guides/video/references/examples/studio-play-phone.json +15 -0
  31. package/guides/video/references/examples/studio-play.json +17 -0
  32. package/guides/video/scripts/capture-game.mjs +175 -0
  33. package/guides/video/scripts/clock.js +136 -0
  34. package/guides/video/scripts/drawover/film.js +58 -0
  35. package/guides/video/scripts/drawover/index.html +9 -0
  36. package/guides/video/scripts/drawover/look.js +115 -0
  37. package/guides/video/scripts/drawover/shots.json +12 -0
  38. package/guides/video/scripts/lib/browser.mjs +87 -0
  39. package/guides/video/scripts/lib/frames.mjs +170 -0
  40. package/guides/video/scripts/lib/remix.mjs +159 -0
  41. package/guides/video/scripts/qa.mjs +127 -0
  42. package/guides/video/scripts/record-fixed.mjs +403 -0
  43. package/guides/video/scripts/record-page.mjs +477 -0
  44. package/guides/video/scripts/tap.js +108 -0
  45. package/guides/video/scripts/video.mjs +888 -0
  46. package/lib/build.mjs +12 -9
  47. package/lib/cloudflare.mjs +6 -11
  48. package/lib/dev.mjs +1 -2
  49. package/lib/doctor.mjs +10 -54
  50. package/lib/mcp-tools.mjs +42 -21
  51. package/lib/mcp.mjs +1 -1
  52. package/lib/media-import.mjs +41 -0
  53. package/lib/media-workflows.mjs +13 -0
  54. package/lib/official-media.mjs +15 -0
  55. package/lib/rules-build.mjs +13 -5
  56. package/lib/scaffold.mjs +2 -2
  57. package/lib/shop-links.mjs +54 -18
  58. package/lib/shop-listen.mjs +43 -0
  59. package/lib/shop-local.mjs +1 -1
  60. package/lib/shop-mcp.mjs +58 -0
  61. package/lib/stripe-connect.mjs +36 -15
  62. package/lib/template-history.json +45 -0
  63. package/lib/upgrade-media.mjs +13 -0
  64. package/lib/upgrade.mjs +10 -0
  65. package/package.json +1 -1
  66. package/parts/PARTS.md +1 -1
  67. package/rules/view.ts +4 -0
  68. package/shop/SHOP.md +66 -34
  69. package/worker/build-id.mjs +1 -1
  70. package/worker/index.mjs +5 -1
  71. package/worker/purchase-reconciliation.mjs +16 -1
  72. package/worker/shop-subscriptions.mjs +2 -0
  73. package/worker/shop.mjs +7 -5
  74. package/worker/stripe-ownership.mjs +7 -0
  75. package/worker/version.mjs +1 -1
  76. package/guides/models/references/models.json +0 -175
  77. package/guides/music/scripts/lib/eleven.mjs +0 -260
  78. package/lib/projects-env.mjs +0 -68
  79. package/lib/projects.mjs +0 -311
  80. package/lib/provider-key.mjs +0 -21
package/lib/scaffold.mjs CHANGED
@@ -535,7 +535,7 @@ where it sells and for Stripe's terms. Homie takes no cut.
535
535
  player refund window covers items; a tip is never taken back by the player.
536
536
  - Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
537
537
  new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
538
- - \`shop check\` validates settings; \`stripe_login\` / \`shop connect\` starts Stripe browser approval.
538
+ - \`shop check\` validates settings; \`stripe_shop_plan\` plans and verifies Stripe official MCP operations; \`stripe_webhook\` installs the signing secret privately.
539
539
  Never ask a person for a key. Follow the result's next step: deploy first if needed, then rerun connect.
540
540
  The default syncs Products, Prices, Payment Links and the webhook through the approved Stripe CLI.
541
541
  The Worker stores only links and a signing secret; buying needs no API key or running AI.
@@ -719,7 +719,7 @@ site/.wrangler/
719
719
  .wrangler/
720
720
  .dev.vars
721
721
  .env
722
- # Stripe Projects (setup --via stripe-projects): credentials stay in its vault and the .env files it syncs, never in git.
722
+ # Provider-owned credential stores and local environment files never belong in git.
723
723
  .env.*
724
724
  .projects/vault/
725
725
  .projects/cache/
@@ -6,7 +6,8 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
6
6
  import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
9
+ export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
10
+ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false }) {
10
11
  const list = async (path, params = {}) => {
11
12
  const all = []; let cursor;
12
13
  do {
@@ -19,15 +20,16 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
19
20
  } while (cursor);
20
21
  return all;
21
22
  };
22
- if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
23
- const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
24
- if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
25
- const names = JSON.parse(secrets.stdout ?? secrets.out);
26
- const has = (name) => names.some((s) => s.name === name);
27
23
  const products = await list('/v1/products');
28
24
  const prices = await list('/v1/prices');
29
25
  const links = await list('/v1/payment_links');
30
26
  const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
27
+ const verifyLink = async (link, params) => {
28
+ if (!verifyObjects) return;
29
+ const actual=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
30
+ const normalized={...actual,line_items:actual.line_items?.data?.map(line=>({...line,price:line.price?.id??line.price}))};
31
+ if(actual.id!==link.id || actual.active!==link.active || actual.url!==link.url || actual.line_items?.has_more===true || !matchesFields(actual.metadata,link.metadata) || !paymentLinkURL(actual.url) || !matchesFields(normalized,params)) throw new Error(`Stripe Payment Link ${link.id} differs from its planned fields. Restore its configuration through Stripe MCP before verifying.`);
32
+ };
31
33
  const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
32
34
  const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
33
35
  const catalogPrices = new Map();
@@ -36,7 +38,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
36
38
  const id = await productIdOf(slug, item.id);
37
39
  keepProducts.add(id);
38
40
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
39
- const fields = { name: item.name, ...(item.blurb ? {description:item.blurb} : {}), tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
41
+ const fields = { name: item.name, description:item.blurb??'', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
40
42
  let product = products.find((p) => p.id === id);
41
43
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
42
44
  else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== (fields.description ?? '') || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
@@ -48,6 +50,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
48
50
  const revision = hash(priceFields);
49
51
  let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
50
52
  if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
53
+ if (verifyObjects && ((price.recurring?.interval_count??1)!==1 || Boolean(price.recurring)!==Boolean(item.interval) || Boolean(price.custom_unit_amount)!==(item.price==='choose'))) throw new Error(`Stripe Price ${price.id} has a different payment type from shop.json`);
54
+ if (verifyObjects && !matchesFields({...price, product:price.product?.id??price.product, ...(price.custom_unit_amount?{custom_unit_amount:{...price.custom_unit_amount,enabled:true}}:{})},priceFields)) throw new Error(`Stripe Price ${price.id} differs from shop.json; restore or archive it through Stripe MCP before planning again.`);
51
55
  if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
52
56
  keepPrices.add(price.id);
53
57
  catalogPrices.set(item.id, price.id);
@@ -61,6 +65,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
61
65
  const linkRevision = hash({ params, item, policy: shop.policy });
62
66
  let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
63
67
  if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
68
+ await verifyLink(link,params);
64
69
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
65
70
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
66
71
  keepLinks.add(link.id);
@@ -83,13 +88,14 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
83
88
  const revision=hash(params);
84
89
  let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
85
90
  if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
91
+ await verifyLink(link,params);
86
92
  if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
87
93
  keepLinks.add(link.id);
88
94
  config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
89
95
  }
90
96
  config.slots=[];
91
97
  const pause=async link=>{
92
- await api('POST',`/v1/payment_links/${link.id}`,{active:false});
98
+ if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
93
99
  for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
94
100
  await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
95
101
  }
@@ -107,11 +113,13 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
107
113
  let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
108
114
  if(!link && (!slot.capacity||slot.active===false))continue;
109
115
  if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
110
- if(slot.confirmed!==undefined && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
116
+ const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
117
+ const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
118
+ if(slot.confirmed!==undefined && !counted && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
111
119
  const count=link?.restrictions?.completed_sessions?.count??0;
112
120
  // Initial publication uses capacity exactly. After a reviewed move/refund the
113
121
  // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
114
- const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
122
+ const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
115
123
  const active=slot.active!==false && limit>count;
116
124
  const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
117
125
  restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
@@ -119,9 +127,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
119
127
  else {
120
128
  // Pause first whenever publishing a changed budget, so old sessions cannot
121
129
  // spend a previous capacity after staff moves or removes a slot.
122
- if(!active || link.restrictions?.completed_sessions?.limit!==limit)await pause(link);
123
- link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active});
130
+ if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
131
+ if (link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
124
132
  }
133
+ await verifyLink(link,params);
125
134
  keepLinks.add(link.id);
126
135
  config.slots.push({...slot,id:link.id,url:link.url,price:catalogPrices.get(item.id),revision:identity,active:link.active,count:link.restrictions?.completed_sessions?.count??0,limit,paused:slot.active===false});
127
136
  }
@@ -132,11 +141,40 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
132
141
  const portals=await list('/v1/billing_portal/configurations');
133
142
  let portal=portals.find(p=>own(p));
134
143
  const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
135
- portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
144
+ if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
136
145
  config.portal=portal.login_page?.url;
137
146
  }
138
- if(local)return {ok:true,command:'shop connect',local:true,mode,config};
139
- const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
147
+ const retire = [
148
+ ...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
149
+ ...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
150
+ ...products.filter(p=>own(p)&&p.active&&!keepProducts.has(p.id)).map(p=>({method:'POST',path:`/v1/products/${p.id}`,params:{active:false}})),
151
+ ];
152
+ return {config, retire};
153
+ }
154
+
155
+ export async function syncPaymentLinks(options) {
156
+ const {root, api, shop, slug, site, mode, w, receipt, saveReceipt, local = false} = options;
157
+ if (local && mode !== 'test') throw new Error('Local checkout requires Stripe test mode');
158
+ const {config, retire} = options.catalog ?? await reconcilePaymentLinks(options);
159
+ if (local) return {ok:true, command:'shop connect', local:true, mode, config};
160
+ const list = async (path,params={}) => {
161
+ const all=[]; let cursor;
162
+ do {
163
+ const page=await api('GET',path,{...params,limit:100,...(cursor?{starting_after:cursor}:{})});
164
+ if (!Array.isArray(page.data)) throw new Error('Incomplete Stripe list');
165
+ all.push(...page.data);
166
+ const next=page.has_more?page.data.at(-1)?.id:null;
167
+ if(page.has_more && (!next || next===cursor))throw new Error('Incomplete Stripe pagination');
168
+ cursor=next;
169
+ }while(cursor);
170
+ return all;
171
+ };
172
+ const secrets=await w(['secret','list']);
173
+ if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
174
+ const names=JSON.parse(secrets.stdout??secrets.out);
175
+ const has=name=>names.some(s=>s.name===name);
176
+
177
+ const purchases = root && !options.catalog ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
140
178
  const purchaseSecrets = {};
141
179
  if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
142
180
  const { generateKeyPair, exportJWK } = await import('jose');
@@ -166,9 +204,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
166
204
  }
167
205
  await purchases?.retire();
168
206
  // Retire only after the replacement configuration and signing secret are installed.
169
- for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
170
- for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
171
- for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
207
+ if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
172
208
  for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
173
209
  return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
174
210
  message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
@@ -0,0 +1,43 @@
1
+ /** Private local Stripe forwarding. Only the allowlisted readiness line reaches the model. */
2
+ import {spawn} from 'node:child_process';
3
+ import {readFileSync,writeFileSync,existsSync,chmodSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ import {stripeProcess,completionArgs} from './stripe-connect.mjs';
6
+ export function saveLocalStripe(root,values) {
7
+ const file=join(root,'.dev.vars');
8
+ let text=existsSync(file)?readFileSync(file,'utf8'):'';
9
+ for(const [key,value] of Object.entries(values)) {
10
+ text=text.replace(new RegExp(`^${key}=.*\\n?`,'gm'),'');
11
+ text+=`\n${key}=${value}\n`;
12
+ }
13
+ writeFileSync(file,text,{mode:0o600});
14
+ chmodSync(file,0o600);
15
+ }
16
+ export async function shopListen(root,{profile,port=8787,log=()=>{},exec=stripeProcess,spawnProcess=spawn}={}) {
17
+ if(!profile || !Number.isInteger(Number(port)) || Number(port)<1 || Number(port)>65535)throw new Error('Supply a sandbox profile and local port');
18
+ const cli=args=>exec(['--project-name',profile,...args],{cwd:root});
19
+ const probe=await cli(['get','/v1/products','-d','limit=1']);
20
+ if(probe.code!==0) {
21
+ const start=await cli(['login','--non-interactive']);
22
+ let login;try{login=JSON.parse(start.stdout);}catch{}
23
+ const complete=completionArgs(login?.next_step);
24
+ const u=login?.browser_url;
25
+ if(!complete || !/^https:\/\/(dashboard|access)\.stripe\.com\//.test(u??''))throw new Error('Update Stripe CLI and retry local browser approval');
26
+ log(`Approve Stripe sandbox access: ${u}`);
27
+ if((await cli(complete)).code!==0)throw new Error('Stripe browser approval did not finish');
28
+ }
29
+ const env={...process.env};for(const key of Object.keys(env))if(key.startsWith('STRIPE_'))delete env[key];
30
+ return new Promise((resolve,reject)=>{
31
+ const child=spawnProcess('stripe',['--project-name',profile,'listen','--all-snapshot','--forward-to',`http://localhost:${port}/api/shop/hook`],{cwd:root,env,stdio:['ignore','pipe','pipe']});
32
+ let buffer='',ready=false;
33
+ const capture=data=>{
34
+ buffer=(buffer+data.toString()).slice(-8192);
35
+ const secret=/whsec_[A-Za-z0-9+/=_-]+(?=[\s\x1b])/.exec(buffer)?.[0];
36
+ if(secret && !ready){saveLocalStripe(root,{STRIPE_WEBHOOK_SECRET:secret});ready=true;buffer='';log('Stripe sandbox forwarding ready. Signing secret saved privately; start/restart homie-studio dev.');}
37
+ };
38
+ child.stdout.on('data',capture);child.stderr.on('data',capture);
39
+ child.on('error',()=>reject(new Error('Install Stripe CLI before local forwarding')));
40
+ const stop=()=>child.kill();process.once('SIGTERM',stop);process.once('SIGINT',stop);
41
+ child.on('close',code=>{process.removeListener('SIGTERM',stop);process.removeListener('SIGINT',stop);resolve({ok:ready&&code===0,command:'shop listen',ready});});
42
+ });
43
+ }
@@ -27,5 +27,5 @@ export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={})
27
27
  vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
28
28
  vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
29
29
  writeFileSync(file,vars,{mode:0o600});
30
- return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
30
+ return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`homie-studio shop listen --profile '${profile}' --port ${origin.port||80}`,'The listener saves the signing secret privately. Start/restart homie-studio dev --lan after it reports ready.','Open the same Wi-Fi address on phones and verify the paid order and app event.']};
31
31
  }
@@ -0,0 +1,58 @@
1
+ /** Pure, incremental Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
2
+ import {createHash} from 'node:crypto';
3
+ import {existsSync, readFileSync, mkdirSync, writeFileSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ import {readShop, SHOP_HOOK_EVENTS} from './shop.mjs';
6
+ import {readStudio, siteUrl} from './studio.mjs';
7
+ import {reconcilePaymentLinks} from './shop-links.mjs';
8
+ import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
9
+ const hash = x => createHash('sha256').update(JSON.stringify(x)).digest('hex');
10
+ export const readKey = (path, params) => JSON.stringify([path,Object.fromEntries(Object.entries(params).sort(([a],[b])=>a.localeCompare(b)))]);
11
+ export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
12
+ const checked=readShop(root);
13
+ if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
14
+ if(!['test','live'].includes(mode))throw new Error('Choose test or live mode');
15
+ const reason=fullerReason(checked.shop);
16
+ if(reason)return {ok:false,why:reason+' Adjust those settings to use Payment Links; no key is required.'};
17
+ const studio=readStudio(root), address=url??siteUrl(root);
18
+ if(!address)return {ok:false,needs:'site',why:'Supply the local test URL, or deploy the studio before planning its public checkout links.'};
19
+ const site=new URL(address).origin;
20
+ const local=new URL(site).protocol==='http:';
21
+ if(local && (mode!=='test' || !/^(localhost|127\.0\.0\.1|10\.\d+\.\d+\.\d+|192\.168\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+)$/.test(new URL(site).hostname)))throw new Error('Local checkout requires a private HTTP address and test mode');
22
+ if(!local && new URL(site).protocol!=='https:')throw new Error('Use the studio HTTPS address');
23
+ const bookingSlots=slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
24
+ const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
25
+ // Reject secrets before persisting observations. Webhook creation is never part of this plan.
26
+ if(/(?:whsec_|[rs]k_(?:test|live)_)/.test(JSON.stringify(observations)))throw new Error('Supply only public catalog reads, never credentials or webhook creation responses');
27
+ const api=async(method,path,params={},idempotency)=>{
28
+ if(method!=='GET')throw {next:{method,path,params,...(idempotency?{idempotency}:{}),retry:'Read fresh before retrying; updates set desired fields and are idempotent.'}};
29
+ const found=observations.find(o=>readKey(o.path,o.params??{})===readKey(path,params));
30
+ if(!found)throw {next:{method,path,params}};
31
+ const body=found.body;
32
+ if(!body || body.error || body.livemode !== undefined && body.livemode !== (mode==='live') || body.data?.some(o=>o.livemode !== undefined && o.livemode !== (mode==='live')))throw new Error('Stripe read has an error or wrong mode');
33
+ return body;
34
+ };
35
+ const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
36
+ webhook:{method:'POST',path:'/v1/webhook_endpoints',private:true,params:{url:`${site}/api/shop/hook`,enabled_events:SHOP_HOOK_EVENTS,api_version:LINKS_STRIPE_VERSION,metadata:{homie:'shop-v1'},description:'Studio shop'}}};
37
+ try {
38
+ const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true});
39
+ if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
40
+ const result={...base,ok:true,verified:true,verifiedAt:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
41
+ return result;
42
+ } catch(e) {
43
+ if(!e.next)throw e;
44
+ return {...base,ok:!verify,verified:false,next:e.next,instructions:e.next.method==='GET'?'Read through Stripe MCP; append {path,params,body} to observations, including every pagination page.':'Perform this exact operation through Stripe MCP. Then discard observations and read fresh before planning again. Do not create the webhook through MCP.'};
45
+ }
46
+ }
47
+ export async function saveMcpVerification(root, options) {
48
+ const result=await shopMcpPlan(root,{...options,verify:true});
49
+ if(result.verified) {
50
+ if(result.local) {
51
+ const {saveLocalStripe}=await import('./shop-listen.mjs');
52
+ saveLocalStripe(root,{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(JSON.stringify(result.config)).toString('base64')});
53
+ }
54
+ mkdirSync(join(root,'.studio'),{recursive:true});
55
+ writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options,result},null,2));
56
+ }
57
+ return result;
58
+ }
@@ -72,6 +72,10 @@ export function completionArgs(step) {
72
72
  }
73
73
 
74
74
  export async function shopConnect(root, options = {}) {
75
+ const fail=(...args)=>{
76
+ const result=failure(...args);
77
+ return options.webhookOnly?{...result,command:'shop webhook',why:result.why.replaceAll('shop connect','shop webhook')}:result;
78
+ };
75
79
  if (options.manual || options.fromClipboard) {
76
80
  const result = await shopConnectManual(root, options);
77
81
  if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
@@ -81,19 +85,32 @@ export async function shopConnect(root, options = {}) {
81
85
  storage = receiptHome(), wrangler = null } = options;
82
86
  const mode = live ? 'live' : 'test';
83
87
  const checked = readShop(root);
84
- if (!checked.ok || checked.absent) return failure('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
85
- if (options.managed === true && checked.shop.till !== 'stripe-managed') return failure('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
88
+ if (!checked.ok || checked.absent) return fail('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
89
+ if (options.managed === true && checked.shop.till !== 'stripe-managed') return fail('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
90
+ let catalog, mcpAccount;
91
+ if (options.webhookOnly) {
92
+ try {
93
+ const {saveMcpVerification} = await import('./shop-mcp.mjs');
94
+ const saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));
95
+ if(!saved.result?.verifiedAt || Date.now()-Date.parse(saved.result.verifiedAt)>15*60_000) return fail('verify','Run shop verify with fresh Stripe MCP reads before installing the webhook (verification is valid for 15 minutes).');
96
+ const verified=await saveMcpVerification(root,saved.options);
97
+ if(!verified.verified || verified.local || verified.mode!==mode || verified.site!==new URL(siteUrl(root)).origin) return fail('verify','Run shop verify with fresh Stripe MCP reads for this site and mode first.');
98
+ mcpAccount=saved.options.account;
99
+ if(!/^acct_[A-Za-z0-9]+$/.test(mcpAccount??''))return fail('account','Verify with the Stripe MCP account ID first.');
100
+ catalog={config:verified.config,retire:verified.retire};
101
+ } catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
102
+ }
86
103
  const id = hash(realpathSync(root)).slice(0, 24);
87
- const profile = `homie-${id}`;
104
+ const profile = options.profile ?? `homie-${id}`;
88
105
  const dir = join(storage, id);
89
106
  mkdirSync(dir, { recursive: true, mode: 0o700 });
90
107
  // Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
91
108
  const lock = lockDeploy(dir);
92
- if (!lock.ok) return failure('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
109
+ if (!lock.ok) return fail('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
93
110
  try {
94
111
  const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
95
112
  const installed = await cli(['--version']);
96
- if (installed.code !== 0) return failure('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
113
+ if (installed.code !== 0) return fail('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
97
114
 
98
115
  // A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
99
116
  let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
@@ -102,21 +119,25 @@ export async function shopConnect(root, options = {}) {
102
119
  const login = json(start.stdout);
103
120
  const complete = completionArgs(login?.next_step);
104
121
  if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
105
- return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.' + stripeReason(start, 'login'));
122
+ return fail('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.' + stripeReason(start, 'login'));
106
123
  }
107
124
  log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
108
125
  open(login.browser_url);
109
126
  const finished = await cli(complete);
110
- if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
127
+ if (finished.code !== 0) return fail('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
111
128
 
112
129
  probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
113
- if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.' + stripeReason(probe, 'GET /v1/products'));
130
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return fail('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.' + stripeReason(probe, 'GET /v1/products'));
131
+ }
132
+ if (options.webhookOnly) {
133
+ const accountResult=await cli(['get','/v1/account',...(live?['--live']:[])]);
134
+ if(accountResult.code!==0 || json(accountResult.stdout)?.id!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
114
135
  }
115
136
  const site = siteUrl(root);
116
- if (!site) return failure('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
137
+ if (!site) return fail('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
117
138
  let target;
118
- try { target = new URL(site); } catch { return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
119
- if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
139
+ try { target = new URL(site); } catch { return fail('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
140
+ if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return fail('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
120
141
  const api = async (method, path, params, idempotency) => {
121
142
  const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
122
143
  for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
@@ -128,20 +149,20 @@ export async function shopConnect(root, options = {}) {
128
149
  return body;
129
150
  };
130
151
  const reason = fullerReason(checked.shop);
131
- if (reason) return failure('fuller-checkout', reason, { mode, connected: true });
152
+ if (reason) return fail('fuller-checkout', reason, { mode, connected: true });
132
153
  const studio = readStudio(root);
133
154
  const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
134
155
  const receiptFile = join(dir, `${mode}.json`);
135
156
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
136
- const result = await syncPaymentLinks({ root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
157
+ const result = await syncPaymentLinks({ catalog, root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
137
158
  bookingSlots: options.bookingSlots ?? (existsSync(join(root,'.studio','booking-slots.json')) ? JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')) : []),
138
159
  saveReceipt: (record) => {
139
160
  writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
140
161
  renameSync(`${receiptFile}.tmp`, receiptFile);
141
162
  } });
142
- return { ...result, profile };
163
+ return { ...result, command:options.webhookOnly?'shop webhook':result.command, profile };
143
164
  } catch (e) {
144
165
  const said = e?.stripeReason ?? '';
145
- return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.' + said);
166
+ return fail('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.' + said);
146
167
  } finally { lock.release(); }
147
168
  }
@@ -3708,6 +3708,51 @@
3708
3708
  }
3709
3709
  }
3710
3710
  }
3711
+ },
3712
+ "0.51.5": {
3713
+ "files": {
3714
+ "CLAUDE.md": "ddf2b2a0376202f6",
3715
+ "README.md": "684b3cca8001673c",
3716
+ "HANDOFF.md": "29337e2ace95b2e7",
3717
+ "games/README.md": "5ad32fe80c61f117",
3718
+ "music/README.md": "a3dc042dadd39df8",
3719
+ "videos/README.md": "16babc3437d5545b",
3720
+ "posts/README.md": "37841aae476bd540",
3721
+ "site/README.md": "1dbdf22c03a1e505",
3722
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3723
+ },
3724
+ "sections": {
3725
+ "AGENTS.md": {
3726
+ "(intro)": "394cbbe934e80722",
3727
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3728
+ "## Layout": "f3a280b8c96cd068",
3729
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3730
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3731
+ "## The site": "0cb8dd9a6f8a0096",
3732
+ "## Making games": "bb715272fd1a615b",
3733
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3734
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3735
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3736
+ "## Servers and AI seats": "1ccfd549d2369a60",
3737
+ "## Selling things (the shop)": [
3738
+ "0df5a5fe1833c624",
3739
+ "43a3556be93d1235"
3740
+ ],
3741
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3742
+ "## Rules": "b2234d80f6cbe71d",
3743
+ "## Telling Homie": "80b46fdd9e578db8",
3744
+ "## Beta": "9d02638207a26e73"
3745
+ }
3746
+ },
3747
+ "named": {
3748
+ "Homie Arcade": {
3749
+ "sections": {
3750
+ "AGENTS.md": {
3751
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3752
+ }
3753
+ }
3754
+ }
3755
+ }
3711
3756
  }
3712
3757
  }
3713
3758
  }
@@ -0,0 +1,13 @@
1
+ /** Retire the exact guidance shipped by Homie without opening credential files. */
2
+ export const RETIRED_MEDIA_GUIDANCE = "### Existing provider credentials\n\nThe tools read `FAL_KEY` or `ELEVENLABS_API_KEY` first, then the plain single-line\nkey in `~/.homie/keys/fal-key` or `~/.homie/keys/elevenlabs-key`. They also accept\n`~/.homie/workspace/fal-key` and `~/.homie/workspace/elevenlabs-key` from earlier\ninstalls. Checks name the source, never the key. Keep these files private (mode\n0600); never copy them into a studio. ElevenLabs also accepts its CLI browser\nsign-in and the existing Stripe Projects connection. Run the tool\u2019s `check`\nbefore declaring a provider disconnected.\n";
3
+ export const OFFICIAL_MEDIA_GUIDANCE = `### Official media connections
4
+
5
+ Use the official fal and ElevenLabs plugins in the AI client, with their browser sign-in.
6
+ Homie never reads provider credentials. Cloudflare deploys use Wrangler login; official
7
+ Cloudflare MCP tools inspect logs, Workers Issues and D1. Follow the matching toolkit's
8
+ guides/studio-setup/references/OFFICIAL-MEDIA.md. Retired Homie generation scripts have
9
+ no compatibility path. Keep existing assets and receipts; do not migrate credential files.
10
+ `;
11
+ export function upgradeMediaGuidance(text) {
12
+ return text.replace(RETIRED_MEDIA_GUIDANCE.trimEnd(), OFFICIAL_MEDIA_GUIDANCE.trimEnd());
13
+ }
package/lib/upgrade.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { upgradeMediaGuidance } from './upgrade-media.mjs';
1
2
  import {appPresetChanges} from './upgrade-app-presets.mjs';
2
3
  import {refreshWorkerConfig} from './worker-config.mjs';
3
4
  /**
@@ -199,8 +200,17 @@ export function upgradePlan(root, { history = readHistory() } = {}) {
199
200
  if (Object.keys(missingScripts).length) changes.push({ file: 'package.json', kind: 'scripts', what: `add scripts: ${Object.keys(missingScripts).join(', ')}`, scripts: missingScripts });
200
201
  if (studio.homie?.studio !== STUDIO_VERSION) changes.push({ file: 'studio.json', kind: 'version', what: `homie.studio ${studio.homie?.studio ?? '(none)'} → ${STUDIO_VERSION}`, to: STUDIO_VERSION });
201
202
 
203
+ const mediaFiles = new Set();
204
+ for (const file of ['AGENTS.md', 'CLAUDE.md', 'README.md']) {
205
+ const from = read(root, file);
206
+ if (from === null) continue;
207
+ const text = upgradeMediaGuidance(from);
208
+ if (text !== from) { changes.push({ file, kind: 'update-file', what: 'replace retired Homie credential guidance with official plugin connections', from, text }); mediaFiles.add(file); }
209
+ }
210
+
202
211
  // AGENTS.md, section by section; the other template files whole.
203
212
  for (const rel of TEMPLATE_FILES) {
213
+ if (mediaFiles.has(rel)) continue;
204
214
  const mine = read(root, rel);
205
215
  const want = tmpl[rel];
206
216
  if (want === undefined) continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.51.4",
3
+ "version": "0.51.5",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/parts/PARTS.md CHANGED
@@ -240,7 +240,7 @@ Paid releases use a **separate private R2 bucket and `PURCHASE_MEDIA` binding**.
240
240
 
241
241
  Stripe's [MPP guide](https://docs.stripe.com/payments/machine/mpp) initializes the Stripe client with a secret credential. Its [x402 guide](https://docs.stripe.com/payments/machine/x402) separates the on-chain facilitator settlement from authenticated PaymentIntent recording. **x402 itself does not require a Stripe API key**; this supported path records money into the seller's Stripe books as Stripe documents. The keyless Payment Link path uses [signed webhooks](https://docs.stripe.com/webhooks) instead of provider reads. CLI OAuth is not exported as a Worker REST credential; see [the setup research](../../../docs/stripe-connect-research.md).
242
242
 
243
- For a fuller connection, the AI operates Stripe's [key-creation page](https://docs.stripe.com/keys), selects the necessary permissions and clicks Copy, then runs `shop connect --from-clipboard` (`--live` for live mode). The local command privately reads the clipboard, verifies the key, makes the webhook and writes secrets to Wrangler stdin, then clears the clipboard. **The human only handles provider sign-in, verification or approval prompts**; their count is controlled by Stripe. Never ask the person to find or paste a key when browser control is available. No supported public API to mint a persistent Stripe REST credential from the CLI approval was established. On macOS this uses `pbpaste`/`pbcopy`, Windows its clipboard commands, and Linux Wayland `wl-paste`/`wl-copy`; other desktops require an equivalent private credential transport. The manual local form remains an explicit fallback, not the default instruction. Live CDP credentials likewise belong to the seller and should be transferred privately by the AI from the provider's setup; no Homie account is involved.
243
+ Owner setup uses Stripe’s official OAuth plugin and Homie’s private webhook installation; read [SHOP.md](../shop/SHOP.md). Runtime-credential machine payment integrations are developer work, not an owner fallback. Never ask an owner to create or copy a key.
244
244
 
245
245
  Turnstile is optional. If the studio configures it, the fallback verifies its hostname and action. The per-address Worker rate limiter remains configurable flood protection. Machine payment proofs are verified by the protocol libraries.
246
246
 
package/rules/view.ts CHANGED
@@ -728,6 +728,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
728
728
  while (stepped < t) {
729
729
  stepped += 1;
730
730
  step(stepped, stepped === t);
731
+ // fire() can read/cache the own entity before this pump advances it.
732
+ // A renderer later in the same callback must see the completed step,
733
+ // not hold that effect's old pose until the next (possibly early) frame.
734
+ presentedEntity = null;
731
735
  if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
732
736
  }
733
737
  }