@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
@@ -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,93 @@
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
+ }
3756
+ },
3757
+ "0.51.6": {
3758
+ "files": {
3759
+ "CLAUDE.md": "ddf2b2a0376202f6",
3760
+ "README.md": "684b3cca8001673c",
3761
+ "HANDOFF.md": "29337e2ace95b2e7",
3762
+ "games/README.md": "5ad32fe80c61f117",
3763
+ "music/README.md": "a3dc042dadd39df8",
3764
+ "videos/README.md": "16babc3437d5545b",
3765
+ "posts/README.md": "37841aae476bd540",
3766
+ "site/README.md": "1dbdf22c03a1e505",
3767
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3768
+ },
3769
+ "sections": {
3770
+ "AGENTS.md": {
3771
+ "(intro)": "394cbbe934e80722",
3772
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3773
+ "## Layout": "f3a280b8c96cd068",
3774
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3775
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3776
+ "## The site": "0cb8dd9a6f8a0096",
3777
+ "## Making games": "bb715272fd1a615b",
3778
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3779
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3780
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3781
+ "## Servers and AI seats": "1ccfd549d2369a60",
3782
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3783
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3784
+ "## Rules": "b2234d80f6cbe71d",
3785
+ "## Telling Homie": "80b46fdd9e578db8",
3786
+ "## Beta": "9d02638207a26e73"
3787
+ }
3788
+ },
3789
+ "named": {
3790
+ "Homie Arcade": {
3791
+ "sections": {
3792
+ "AGENTS.md": {
3793
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3794
+ }
3795
+ }
3796
+ }
3797
+ }
3711
3798
  }
3712
3799
  }
3713
3800
  }
@@ -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.6",
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
  }
package/shop/SHOP.md CHANGED
@@ -101,26 +101,68 @@ One order stores every line and its item snapshot. `/api/shop/mine` lists the li
101
101
 
102
102
  `homie-studio shop init --supporter` writes an example item and `SELLING.md`, without a policy, cap or refund
103
103
  window. `shop check` validates settings; the build uses the same validation.
104
- `shop connect` (MCP `stripe_login`) starts Stripe's official browser approval, sandbox/test first.
105
- If Stripe CLI is missing, the result gives `npm install -g @stripe/cli@latest` for the AI to run.
106
- The person never supplies a key to the AI. A studio-specific CLI profile avoids borrowing another studio's login.
107
- If the studio is not deployed yet, approval is retained: deploy with `studio_deploy` (and
108
- `cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
109
-
110
- **The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
111
- Payment Link for each paid item, adjustable-quantity menus, and one capacity-limited link per published booking slot. Tip Prices use custom amounts; free orders stay local. It creates
112
- an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
113
- and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
114
- API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
115
- Price versions, changed links get new versions, and removed objects are archived. Old paid orders
116
- keep their frozen item definitions. Receipts contain no credentials. Unchanged runs create nothing.
117
-
118
- With an existing account and deployed studio, the person approves Stripe CLI once; an already
119
- connected profile needs zero new approvals. An MCP-only grant is separate and is never extracted.
120
- The AI installs tooling, creates/syncs objects and stores the secret. New account verification,
121
- activation, administrator access settings and Cloudflare authorization are separate prerequisites.
122
- `shop connect --live` requires the owner's go-live request. `--renew` retries browser authorization.
123
- Never request a key from the person for the default flow.
104
+ ## Default: Stripe's official plugin
105
+
106
+ Use Stripe's official plugin, authenticated with its browser OAuth approval, for Products,
107
+ Prices, Payment Links, menu links, per-slot booking links, monthly giving and the customer
108
+ portal. Install with `stripe agent setup` if needed; the MCP URL is
109
+ `https://mcp.stripe.com`. Current tools are `stripe_api_search`, `stripe_api_details`,
110
+ `stripe_api_read` and `stripe_api_write`. Discover the operation and its schema before
111
+ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
112
+
113
+ 1. Create/check `shop.json`. Read the app's published slot records and supply the complete
114
+ slot manifest described below. Select the owner's account and sandbox in Stripe's plugin;
115
+ read `/v1/account` to record its account ID. Live mode only after the owner requests it.
116
+ 2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
117
+ `observations: []`. CLI equivalent: `homie-studio shop plan --have plan-input.json`.
118
+ That JSON file contains the same fields. A returned `next` is the exact operation:
119
+ method, API path, parameters, API version and (for creates) idempotency key. Updates set desired fields; read before retrying. Metadata
120
+ scopes objects to this studio and origin; prices and links have content revisions.
121
+ 3. Perform each `next` through Stripe's official MCP. For GET, append
122
+ `{ "path": "…", "params": {…}, "body": <Stripe response> }` to `observations`
123
+ and plan again. Follow every pagination request. After a write, discard observations
124
+ and read fresh. Replanning resolves real Stripe IDs; never invent IDs or substitute
125
+ a different price. Reuse the supplied idempotency key where the tool supports it;
126
+ otherwise read before retrying and match the stable product ID/revision metadata.
127
+ 4. When no writes remain, use fresh reads with `stripe_shop_plan` `verify: true`
128
+ (CLI `homie-studio shop verify --have plan-input.json`). It checks the catalog,
129
+ actual link fields and line items, menus, slot limits and portal settings against
130
+ shop.json and the slot manifest. A mismatch blocks verification. It saves only public
131
+ configuration to `.studio/stripe-mcp.json`; local test configuration goes to `.dev.vars`.
132
+ 5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
133
+ Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
134
+ checks the same account as the MCP, and installs the signing secret and verified
135
+ links through Wrangler stdin. The CLI needs one separate browser approval if its
136
+ studio profile is not already connected. MCP approval cannot be reused or exported.
137
+ Cloudflare authorization and an existing deployment are prerequisites.
138
+ 6. After installation, perform the returned `retire` operations through Stripe MCP to
139
+ archive replaced catalog objects, then verify fresh reads again. Test a sandbox
140
+ purchase and its signed event, order grant, booking capacity or recurring invoice;
141
+ a browser return is never proof. Stripe CLI `trigger` can send a test event, and
142
+ `events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
143
+ endpoint's delivery result and the app's verified records. Do not type a card into
144
+ a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
145
+
146
+ Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
147
+
148
+ The owner never handles an API key or signing secret. No Homie backend handles payments.
149
+ The Worker needs only public link configuration and the signing secret; buyers pay
150
+ Stripe directly even when the AI and the owner's computer are offline. The old
151
+ `stripe_login` / `shop connect` flow remains an alternative that syncs everything
152
+ through Stripe CLI browser approval. `--renew` renews that approval; `--live` requires
153
+ the owner's go-live request. Never read CLI config or print login-completion output.
154
+
155
+ For local checkout, pass `url: "http://localhost:8787"` (or the private Wi-Fi address)
156
+ and `mode: "test"` to the same plan/verify flow; the official plugin makes the links.
157
+ Run `homie-studio shop listen --profile <sandbox-profile>` in a background terminal.
158
+ Stripe CLI is required for forwarding; one browser approval is enough if needed.
159
+ The command captures and saves the listener secret privately. Start/restart
160
+ `homie-studio dev --lan` once it reports ready and keep the listener running.
161
+ Never copy the listener's raw output into the conversation.
162
+
163
+ For a faster local iteration, `homie-studio build --quick` skips generated rules play
164
+ and the snapshot smoke-run, while retaining compilation, guards and type checks.
165
+ Run a full build before release; quick output is not evidence that play checks passed.
124
166
 
125
167
  At purchase time, the Worker saves an opaque order reference and sends it as the link's
126
168
  `client_reference_id`. The buyer confirms quantity or tip amount on Stripe. A signed snapshot
@@ -142,17 +184,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
142
184
  | Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
143
185
  | Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
144
186
 
145
- A key alone never selects the fuller path. It requires explicit `STRIPE_CHECKOUT_MODE=sessions` as well as a Worker key. Only if requested, explain: “The optional session connection adds exact app-built carts, spending reservations, expiring holds and runtime refunds.”
146
- The explicit `shop connect --manual` path remains available, including `--live`, but requires the
147
- owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
148
- Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
149
- When switching to spending restrictions, the AI must deactivate previously issued Payment Links through
150
- the approved CLI and resolve in-flight checkouts before relying on those restrictions; switching credentials
151
- does not cancel old Stripe sessions. The runtime OAuth finding and sources are in `docs/stripe-connect-research.md` in the source checkout.
152
-
153
- `shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Stripe's optional MCP
154
- is useful for owner-requested refunds and analytics; honor its approval links. Do not create a
155
- secret-returning webhook through a conversation tool: the private connect process owns that call.
187
+ `shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Use Stripe's official MCP for owner-requested refunds and analytics; honor its approval links.
156
188
 
157
189
  ## Payment guarantees and schema
158
190
 
@@ -220,7 +252,7 @@ For referrals, use Stripe Connect to split payments or a Stripe-based affiliate
220
252
 
221
253
  ## Keyless menus, paid seats and monthly giving (0.51.0)
222
254
 
223
- Use `shop connect` by default. All objects belong to the owner's Stripe account and
255
+ Use the official MCP plan/verify flow by default. All objects belong to the owner's Stripe account and
224
256
  are created through its approved CLI session. Homie has no platform key, OAuth
225
257
  backend, payment intermediary or cut. A desktop is needed to sync, not to take money.
226
258
 
@@ -251,7 +283,7 @@ data goes in that reference. A return URL is never proof of payment.
251
283
  collection with integer `capacity` and a server-managed `bookings` collection with
252
284
  `"booking":{"slots":"slots","item":"full-tune"}`. Publish slot records, then have
253
285
  the owner's AI write the complete manifest to `.studio/booking-slots.json` and run
254
- `shop connect`, or call local MCP `stripe_bookings_sync` with the same `slots` array:
286
+ `stripe_shop_plan` with the same `slots` array (the CLI alternative is `stripe_bookings_sync`):
255
287
 
256
288
  ```json
257
289
  [{"app":"tune","collection":"bookings","slots":"slots","slot":"sat-0900","item":"full-tune","capacity":1}]
@@ -303,7 +335,7 @@ Signed `subscription.started`, `.renewed`, `.failed`, `.cancelled` events drive
303
335
  membership and giving. Membership access ends at the paid invoice line's period end;
304
336
  failed invoices cannot extend it and cancellation ends access. Unknown Prices grant
305
337
  nothing. Keyless portal configuration supports cancellation, payment-method updates
306
- and invoice history. `shop connect` enables `login_page.enabled` through the CLI API
338
+ and invoice history. The official MCP plan enables `login_page.enabled` through Stripe’s API
307
339
  and stores Stripe's no-code login URL; **no Dashboard toggle is required**. Givers
308
340
  verify their email with Stripe to manage/cancel. The buyer-owned `/api/shop/portal`
309
341
  returns this URL. Plan changes require deliberate portal configuration and fulfilment
@@ -339,3 +371,47 @@ dev bindings and disposable studios when finished.
339
371
  See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
340
372
  `shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
341
373
  creates a paid order and `order.paid` with the subscription’s original app reference.
374
+
375
+ ## Preserve a local basket with keyless checkout
376
+
377
+ For a venue that wants the phone's exact items and quantities, opt into a saved
378
+ basket in `shop.json` before syncing Payment Links:
379
+
380
+ ```json
381
+ "basket": {
382
+ "name": "Round for your table",
383
+ "description": "Enter the total shown on your pub basket. Any shortfall is settled at the bar."
384
+ }
385
+ ```
386
+
387
+ Use Stripe (`till: "stripe"`) and tax-inclusive menu prices (`automaticTax: false`).
388
+ Sync creates one custom-amount Price and Payment Link per app, with one line and
389
+ quantity one. `shop.checkout({reference: "table-7"})` saves the validated basket
390
+ lines, quantities, total and reference in the studio's D1 before opening Stripe.
391
+ Even a basket containing only one item keeps its exact quantity. The opaque order
392
+ ID travels as `client_reference_id`; no Stripe API key or Homie payment relay is
393
+ used by the Worker. Existing menus without `basket` retain their behavior.
394
+
395
+ The link prefills the saved total using Stripe's documented
396
+ [`prefilled_amount`](https://docs.stripe.com/payment-links/customize#customize-checkout-with-url-parameters)
397
+ in minor currency units. The guest can edit it, so a preset is never payment proof.
398
+ [`custom_unit_amount.preset`](https://docs.stripe.com/api/prices/create?query=custom_unit_amount)
399
+ is the Price-level fallback, not a per-buyer setting. Never mutate that shared Price
400
+ at checkout: concurrent baskets would race. Both the Price fallback and the URL
401
+ override were verified in the Homie sandbox. Invalid overrides fall back to the
402
+ stored preset or an empty amount; keep the expected total visible on the phone.
403
+
404
+ After a signed paid session matches the frozen link, revision, mode, currency and
405
+ order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
406
+ payment), `basketTotal` (expected total), and `settlement` (`paid` or `short`).
407
+ Line `amount` is the saved extended amount (unit price × quantity); line `total`
408
+ is its proportionally allocated share of the actual aggregate payment for refunds.
409
+ A short payment emits the event so staff can resolve it, but grants no automatic
410
+ entitlements. Apps must compare the actual payment with `basketTotal`, show a
411
+ shortfall explicitly, and deduplicate by order ID. More than the total is paid.
412
+ Reopening and paying the link creates another order for the same frozen basket;
413
+ repeated delivery of the same session never creates another round.
414
+
415
+ The Stripe heading is the connected Stripe account's business name. A sandbox
416
+ account named “Homie sandbox” shows that name; a real pub's account shows the pub's
417
+ name. The basket's product name and description use the pub's own wording.
@@ -47,6 +47,7 @@ export async function appAccess(request, env, meta, url) {
47
47
  const caller = toolIdentity(request);
48
48
  if (caller) {
49
49
  if (caller.owner) return { ok: true, role, can: declared.can };
50
+ if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
50
51
  if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
51
52
  const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
52
53
  return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.51.4","sha256":"6286b3013c1ad438111eaed4338ccbffa2f85bf2a765f9f007155a809c414f71"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.6","sha256":"035298d6bd38bab86e0471ea08819786995a53cedb8da961b595506e5403b410"});
@@ -25,10 +25,16 @@ export async function readableFeed(env,cat,caller,name) {
25
25
  // Non-record events have no app read capability; they remain owner-only.
26
26
  return caller.owner===true;
27
27
  }
28
- export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER}={}) {
28
+ export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER,caller}={}) {
29
29
  const feed=eventFeeds(env)[name];if(!feed)return [];
30
- const rows=(await env.DB.prepare('SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 ORDER BY seq LIMIT 100').bind(feed.event,after,through).all()).results;
31
- return rows.map(r=>({...r,data:JSON.parse(r.data)}));
30
+ const scopedApp=env.HOMIE_LOCAL_TEST_SECRET&&caller?.localTest&&caller.testScope&&feed.event==='record.changed'?`${feed.app}:check:${caller.testScope}`:null;
31
+ const rows=(await env.DB.prepare("SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 AND (?4 IS NULL OR json_extract(data,'$.app')=?4) ORDER BY seq LIMIT 100").bind(feed.event,after,through,scopedApp).all()).results;
32
+ const events=rows.map(r=>({...r,data:JSON.parse(r.data)}));
33
+ if(env.HOMIE_LOCAL_TEST_SECRET && caller?.localTest && caller.testScope && feed.event==='record.changed') {
34
+ const app=`${feed.app}:check:${caller.testScope}`;
35
+ return events.filter(e=>e.data.app===app).map(e=>({...e,data:{...e.data,app:feed.app}}));
36
+ }
37
+ return events;
32
38
  }
33
39
  export function matchesFeed(feed,event) {
34
40
  return feed.event===event.type && (feed.event!=='record.changed'||(event.data.app===feed.app&&event.data.collection===feed.collection));
package/worker/index.mjs CHANGED
@@ -189,7 +189,11 @@ async function availableShellShop(env, cat, game, options) {
189
189
  // The shell can show a disabled catalog before payment setup is complete.
190
190
  // Checkout readiness is enforced by the shop API, separately from visibility.
191
191
  const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
192
- return (preview && !shopOf(cat)?.broken || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
192
+ const shop = shopOf(cat);
193
+ // Free services must retain their bridge in a mixed free/paid catalogue.
194
+ // Paid readiness, policy and atomic booking capacity remain API decisions.
195
+ const free = shop?.open && !shop.broken && shop.items.some(i => i.price === 0 && !i.interval && (!i.game || i.game === game));
196
+ return (preview && !shop?.broken || free || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
193
197
  }
194
198
 
195
199
  async function postsOf(env, origin) {
@@ -2,6 +2,10 @@
2
2
  const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
3
3
  export async function localCaller(request, env) {
4
4
  if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
5
+ const session=await localSession(request,env);
6
+ // A supplied but invalid signed session must never fall back to owner.
7
+ if(request.headers.get('cookie')?.includes('homie_test=')&&!session)return null;
8
+ if(session)return {id:`local-test-${session.app}-${session.role}`,owner:session.role==='owner',client:null,localTest:true,roles:{[session.app]:[session.role]},testApp:session.app,testScope:session.scope??null};
5
9
  return {id:'local-test-owner',owner:true,client:null};
6
10
  }
7
11
  export async function localSession(request, env) {
@@ -41,9 +41,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
41
41
  const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
42
42
  WHERE type=?1 AND seq>?2 AND seq<=?3
43
43
  AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
44
- ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?feed.app:null,feed.collection??null,max+1).all()).results;
44
+ ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1).all()).results;
45
45
  const hasMore=rows.length>max,page=rows.slice(0,max);
46
- const events=page.map(row=>occurrence(params.name,{...row,data:JSON.parse(row.data)},cursorOf(params.name,row.seq)));
46
+ const events=page.map(row=>{const data=JSON.parse(row.data);if(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
47
47
  const after=hasMore?page.at(-1).seq:start.through;
48
48
  return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
49
49
  }