@homie-rocks/studio 0.51.5 → 0.51.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/shop-mcp.mjs CHANGED
@@ -1,4 +1,5 @@
1
- /** Pure, incremental Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
1
+ import {canonicalJson} from '../worker/canonical-json.mjs';
2
+ /** Pure, batched Stripe plan. The owner's AI supplies public API reads; no Stripe transport here. */
2
3
  import {createHash} from 'node:crypto';
3
4
  import {existsSync, readFileSync, mkdirSync, writeFileSync} from 'node:fs';
4
5
  import {join} from 'node:path';
@@ -7,7 +8,8 @@ import {readStudio, siteUrl} from './studio.mjs';
7
8
  import {reconcilePaymentLinks} from './shop-links.mjs';
8
9
  import {fullerReason, LINKS_STRIPE_VERSION} from '../worker/shop-links.mjs';
9
10
  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 const readKey = (path, params) => canonicalJson([path,params]);
12
+ const slotsOf=(root,slots)=>slots??(existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):[]);
11
13
  export async function shopMcpPlan(root, {observations = [], mode = 'test', url, slots, verify = false} = {}) {
12
14
  const checked=readShop(root);
13
15
  if(!checked.ok || checked.absent) return {ok:false,why:'Create and check shop.json first.'};
@@ -20,28 +22,41 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
20
22
  const local=new URL(site).protocol==='http:';
21
23
  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
24
  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')):[]);
25
+ const bookingSlots=slotsOf(root,slots);
24
26
  const slug=local?'local-'+createHash('sha256').update(root).digest('hex').slice(0,16):studio.slug;
25
27
  // Reject secrets before persisting observations. Webhook creation is never part of this plan.
26
28
  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
29
  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}};
30
+ let found=observations.findLast(o=>(o.method??'GET')===method && readKey(o.path,o.params??{})===readKey(path,params));
31
+ if(method==='GET'&&path.split('/').length>=4&&!path.endsWith('/search')) {
32
+ const written=observations.findLast(o=>o.method==='POST'&&o.body?.id===path.split('/').at(-1));
33
+ if(written&&observations.indexOf(written)>observations.indexOf(found)) {
34
+ const lines=written.body.line_items??found?.body?.line_items;
35
+ found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
36
+ }
37
+ }
38
+ if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
31
39
  const body=found.body;
32
40
  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
41
  return body;
34
42
  };
35
43
  const base={command:verify?'shop verify':'shop plan',mode,site,apiVersion:LINKS_STRIPE_VERSION,
36
44
  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'}}};
45
+ let saved;try{saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));}catch{}
46
+ const knownLinks=saved?.result?.site===site&&saved.result.mode===mode?[...(saved.result.config.items??[]),...(saved.result.config.menus??[]),...(saved.result.config.slots??[])].map(l=>l.id):[];
47
+ for(const observation of observations) {
48
+ const body=observation.body;
49
+ if(/^plink_[A-Za-z0-9]+$/.test(body?.id??'')&&body.metadata?.homie==='shop-links-v1'&&body.metadata.studio===slug&&body.metadata.origin===site)knownLinks.push(body.id);
50
+ }
37
51
  try {
38
- const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true});
52
+ const catalog=await reconcilePaymentLinks({api,shop:checked.shop,slug,site,mode,bookingSlots,verifyObjects:true,scoped:true,knownLinks,knownPortal:saved?.result?.site===site&&saved.result.mode===mode?saved.result.config.portalId:undefined});
39
53
  if(checked.shop.items.some(item=>item.interval) && !/^https:\/\/billing\.stripe\.com\//.test(catalog.config.portal??''))throw new Error('Invalid Stripe portal URL');
40
54
  const result={...base,ok:true,verified:true,verifiedAt:new Date().toISOString(),...catalog,local, fingerprint:hash({shop:checked.shop,bookingSlots,site,mode,catalog})};
41
55
  return result;
42
56
  } 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.'};
57
+ if(!e.next&&!e.operations)throw e;
58
+ const operations=e.operations??[e.next];
59
+ return {...base,ok:!verify,verified:false,operations,next:operations[0],instructions:'Perform every independent operation in this batch through Stripe MCP. Append {method,path,params,body} for reads AND writes and replan to resolve dependencies. Keep observations for this setup; never create a webhook through MCP.'};
45
60
  }
46
61
  }
47
62
  export async function saveMcpVerification(root, options) {
@@ -52,7 +67,19 @@ export async function saveMcpVerification(root, options) {
52
67
  saveLocalStripe(root,{STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:'base64:'+Buffer.from(JSON.stringify(result.config)).toString('base64')});
53
68
  }
54
69
  mkdirSync(join(root,'.studio'),{recursive:true});
55
- writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options,result},null,2));
70
+ const slots=slotsOf(root,options.slots);
71
+ if(options.slots!==undefined)writeFileSync(join(root,'.studio','booking-slots.json'),JSON.stringify(slots));
72
+ writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{account:options.account,mode:result.mode,slots,url:result.site},source:hash({shop:readShop(root).shop,slots}),result:{verified:true,verifiedAt:result.verifiedAt,local:result.local,site:result.site,mode:result.mode,config:result.config}},null,2));
56
73
  }
57
74
  return result;
58
75
  }
76
+
77
+ /** Public verified configuration is tied to the current shop and origin, never to stored API responses. */
78
+ export function verifiedMcpConfig(root) {
79
+ const file=join(root,'.studio','stripe-mcp.json');if(!existsSync(file))return null;
80
+ const saved=JSON.parse(readFileSync(file,'utf8'));
81
+ if(saved.result?.local)return null; // Local builds read .dev.vars, never production bindings.
82
+ const slots=existsSync(join(root,'.studio','booking-slots.json'))?JSON.parse(readFileSync(join(root,'.studio','booking-slots.json'),'utf8')):saved.options?.slots??[];
83
+ if(!saved.result?.verified || saved.source!==hash({shop:readShop(root).shop,slots}) || saved.result.site!==new URL(siteUrl(root)).origin)throw new Error('Shop configuration changed; verify through stripe_shop_plan before deploying or installing its webhook.');
84
+ return saved;
85
+ }
@@ -90,14 +90,14 @@ export async function shopConnect(root, options = {}) {
90
90
  let catalog, mcpAccount;
91
91
  if (options.webhookOnly) {
92
92
  try {
93
- const {saveMcpVerification} = await import('./shop-mcp.mjs');
94
- const saved=JSON.parse(readFileSync(join(root,'.studio','stripe-mcp.json'),'utf8'));
93
+ const {verifiedMcpConfig} = await import('./shop-mcp.mjs');
94
+ const saved=verifiedMcpConfig(root);
95
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);
96
+ const verified=saved.result;
97
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
98
  mcpAccount=saved.options.account;
99
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};
100
+ catalog={config:verified.config,retire:[]};
101
101
  } catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
102
102
  }
103
103
  const id = hash(realpathSync(root)).slice(0, 24);
@@ -3753,6 +3753,90 @@
3753
3753
  }
3754
3754
  }
3755
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
+ }
3798
+ },
3799
+ "0.51.7": {
3800
+ "files": {
3801
+ "CLAUDE.md": "ddf2b2a0376202f6",
3802
+ "README.md": "684b3cca8001673c",
3803
+ "HANDOFF.md": "29337e2ace95b2e7",
3804
+ "games/README.md": "5ad32fe80c61f117",
3805
+ "music/README.md": "a3dc042dadd39df8",
3806
+ "videos/README.md": "16babc3437d5545b",
3807
+ "posts/README.md": "37841aae476bd540",
3808
+ "site/README.md": "1dbdf22c03a1e505",
3809
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3810
+ },
3811
+ "sections": {
3812
+ "AGENTS.md": {
3813
+ "(intro)": "394cbbe934e80722",
3814
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3815
+ "## Layout": "f3a280b8c96cd068",
3816
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3817
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3818
+ "## The site": "0cb8dd9a6f8a0096",
3819
+ "## Making games": "bb715272fd1a615b",
3820
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3821
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3822
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3823
+ "## Servers and AI seats": "1ccfd549d2369a60",
3824
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3825
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3826
+ "## Rules": "b2234d80f6cbe71d",
3827
+ "## Telling Homie": "80b46fdd9e578db8",
3828
+ "## Beta": "9d02638207a26e73"
3829
+ }
3830
+ },
3831
+ "named": {
3832
+ "Homie Arcade": {
3833
+ "sections": {
3834
+ "AGENTS.md": {
3835
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3836
+ }
3837
+ }
3838
+ }
3839
+ }
3756
3840
  }
3757
3841
  }
3758
3842
  }
@@ -1,3 +1,4 @@
1
+ import {verifiedMcpConfig} from './shop-mcp.mjs';
1
2
  import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
2
3
  import {join} from 'node:path';
3
4
  import {configPath,readStudio,layoutOf,listExperiences} from './studio.mjs';
@@ -26,6 +27,8 @@ export function updateWorkerConfig(root,generated) {
26
27
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
27
28
  }
28
29
  const next=merge(base,custom);
30
+ const verified=verifiedMcpConfig(root);
31
+ if(verified)next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS:JSON.stringify(verified.result.config)};
29
32
  // Crons are additive: studio-owned entries cannot erase required toolkit ticks.
30
33
  const crons=[...new Set([...(base.triggers?.crons??[]),...(custom.triggers?.crons??[])])].sort();
31
34
  next.triggers={...next.triggers,crons}; // [] explicitly removes old generated triggers in Wrangler.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.51.5",
3
+ "version": "0.51.7",
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/shop/SHOP.md CHANGED
@@ -115,20 +115,30 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
115
115
  read `/v1/account` to record its account ID. Live mode only after the owner requests it.
116
116
  2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
117
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`.
118
+ That JSON file contains the same fields. `operations` contains every independent
119
+ operation currently ready, with method, path, parameters and create idempotency key.
120
+ Execute the batch through Stripe's official plugin, then replan for dependent work.
121
+ `next` remains the first operation for older clients.
122
+ 3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
123
+ for **writes as well as reads**. Keep this setup's observations across batches.
124
+ Product creation includes its default Price; link creation expands line items.
125
+ Product and Price Search use studio/origin metadata. Existing links are fetched by
126
+ IDs from the verified configuration; Stripe has no Payment Link metadata search.
127
+ Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
128
+ it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
129
+ window, recover the studio's existing link IDs before setup to avoid duplicates.
130
+ Follow search `next_page` cursors. Search is eventually consistent: write responses
131
+ are the immediate evidence. On an uncertain write, retry its exact idempotency key;
132
+ never discard successful responses or invent IDs. Start a new read set when checking
133
+ later external edits, changing accounts, or changing mode.
134
+ 4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
135
+ `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
136
+ line items, menus, slot limits and portal settings. It saves compact public Worker
137
+ configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
138
+ Deploy installs those links. Checkout then reports only `webhook-secret` missing;
139
+ it cannot open until signed payment events can confirm orders. Changing shop.json,
140
+ the slot manifest or origin requires verification again. Local test configuration
141
+ goes to `.dev.vars`.
132
142
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
133
143
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
134
144
  checks the same account as the MCP, and installs the signing secret and verified
@@ -136,7 +146,7 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
136
146
  studio profile is not already connected. MCP approval cannot be reused or exported.
137
147
  Cloudflare authorization and an existing deployment are prerequisites.
138
148
  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
149
+ archive replaced catalog objects. Use fresh scoped reads for later drift checks. Test a sandbox
140
150
  purchase and its signed event, order grant, booking capacity or recurring invoice;
141
151
  a browser return is never proof. Stripe CLI `trigger` can send a test event, and
142
152
  `events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
@@ -145,6 +155,19 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
145
155
 
146
156
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
147
157
 
158
+ **Why the separate approval (checked 2026-10-14).** Stripe's
159
+ [event destination API](https://docs.stripe.com/api/v2/core/event-destinations/create)
160
+ also returns its signing secret only in the private create response when explicitly
161
+ included. Thin events still require signature verification and authenticated retrieval
162
+ of event details; they do not supply a secret handoff to Wrangler. The
163
+ [official MCP](https://docs.stripe.com/mcp) documents no credential or secret transfer
164
+ to a studio Worker. Therefore the AI starts `stripe_webhook`, the owner clicks
165
+ Approve once in Stripe's CLI browser page if needed, and the tool completes endpoint
166
+ creation and Wrangler secret installation privately. MCP approval and CLI approval
167
+ are separate. No API key is requested, no secret enters chat, and Homie is never in
168
+ the money path. A connected studio CLI profile needs no further approval.
169
+
170
+
148
171
  The owner never handles an API key or signing secret. No Homie backend handles payments.
149
172
  The Worker needs only public link configuration and the signing secret; buyers pay
150
173
  Stripe directly even when the AI and the owner's computer are offline. The old
@@ -371,3 +394,47 @@ dev bindings and disposable studios when finished.
371
394
  See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
372
395
  `shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
373
396
  creates a paid order and `order.paid` with the subscription’s original app reference.
397
+
398
+ ## Preserve a local basket with keyless checkout
399
+
400
+ For a venue that wants the phone's exact items and quantities, opt into a saved
401
+ basket in `shop.json` before syncing Payment Links:
402
+
403
+ ```json
404
+ "basket": {
405
+ "name": "Round for your table",
406
+ "description": "Enter the total shown on your pub basket. Any shortfall is settled at the bar."
407
+ }
408
+ ```
409
+
410
+ Use Stripe (`till: "stripe"`) and tax-inclusive menu prices (`automaticTax: false`).
411
+ Sync creates one custom-amount Price and Payment Link per app, with one line and
412
+ quantity one. `shop.checkout({reference: "table-7"})` saves the validated basket
413
+ lines, quantities, total and reference in the studio's D1 before opening Stripe.
414
+ Even a basket containing only one item keeps its exact quantity. The opaque order
415
+ ID travels as `client_reference_id`; no Stripe API key or Homie payment relay is
416
+ used by the Worker. Existing menus without `basket` retain their behavior.
417
+
418
+ The link prefills the saved total using Stripe's documented
419
+ [`prefilled_amount`](https://docs.stripe.com/payment-links/customize#customize-checkout-with-url-parameters)
420
+ in minor currency units. The guest can edit it, so a preset is never payment proof.
421
+ [`custom_unit_amount.preset`](https://docs.stripe.com/api/prices/create?query=custom_unit_amount)
422
+ is the Price-level fallback, not a per-buyer setting. Never mutate that shared Price
423
+ at checkout: concurrent baskets would race. Both the Price fallback and the URL
424
+ override were verified in the Homie sandbox. Invalid overrides fall back to the
425
+ stored preset or an empty amount; keep the expected total visible on the phone.
426
+
427
+ After a signed paid session matches the frozen link, revision, mode, currency and
428
+ order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
429
+ payment), `basketTotal` (expected total), and `settlement` (`paid` or `short`).
430
+ Line `amount` is the saved extended amount (unit price × quantity); line `total`
431
+ is its proportionally allocated share of the actual aggregate payment for refunds.
432
+ A short payment emits the event so staff can resolve it, but grants no automatic
433
+ entitlements. Apps must compare the actual payment with `basketTotal`, show a
434
+ shortfall explicitly, and deduplicate by order ID. More than the total is paid.
435
+ Reopening and paying the link creates another order for the same frozen basket;
436
+ repeated delivery of the same session never creates another round.
437
+
438
+ The Stripe heading is the connected Stripe account's business name. A sandbox
439
+ account named “Homie sandbox” shows that name; a real pub's account shows the pub's
440
+ 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.5","sha256":"1e11f3b33959ff475ed31b5e5e0b5b4e7a155be104d47db80901422409830b23"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.7","sha256":"1f82ad29d4066e96c2d8ed5c02c405f030ec270917c73b68543042cf53c5e6a7"});
@@ -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));
@@ -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
  }
@@ -12,8 +12,10 @@ export async function allowed(audience, caller, env) {
12
12
  if (audience === 'public') return true;
13
13
  if (!caller.id) return false;
14
14
  if (audience === 'signed-in') return true;
15
+ if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
15
16
  if (audience === 'owner') return caller.owner === true;
16
17
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
18
+ if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
17
19
  return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
18
20
  }
19
21
  return false;
@@ -38,11 +40,19 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
38
40
  const meta = cat.games.find(g=>g.id===app && g.kind==='app');
39
41
  if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw Object.assign(new Error('Unknown app or collection; collection names start with a lowercase letter and use lowercase letters, digits and hyphens (1–40). Record id: 1–64 letters, digits, underscore or hyphen.'),{name:'StudioOperationError'});
40
42
  if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
43
+ if(!role) {
44
+ const candidates=[];
45
+ for(const [name,declared] of Object.entries(meta.roles??{})) {
46
+ if(!declared.signIn || await allowed({app,role:name},caller,env))candidates.push([name,declared]);
47
+ }
48
+ candidates.sort((a,b)=>Number(b[1].signIn===true)-Number(a[1].signIn===true)||(b[1].can?.length??0)-(a[1].can?.length??0)||a[0].localeCompare(b[0]));
49
+ role=candidates[0]?.[0];
50
+ }
41
51
  const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
42
52
  const method = {read:'GET',create:'POST',update:'PUT',delete:'DELETE'}[operation];if (!method) throw new Error('unsupported operation');
43
53
  const request = withToolIdentity(new Request(url,{method,headers:{origin,'content-type':'application/json'},...(method==='GET'?{}:{body:JSON.stringify({data,version})})}),caller);
44
54
  const response = await appRecordsRoute(request,env,meta,url,url.pathname.slice(app.length+2));
45
- const result = await response.json(); if(!response.ok) {const error=new Error(result.error);error.name='StudioOperationError';throw error;}return result;
55
+ const result = await response.json(); if(!response.ok) {const error=new Error(`${result.error} (role: ${role??'none'})`);error.name='StudioOperationError';throw error;}return {...result,role};
46
56
  };
47
57
  const office = async (path, body, query={}) => {
48
58
  const delegated=definition.delegateOffice && await allowed(definition.audience,caller,env);
@@ -86,7 +96,7 @@ export function builtins(env,cat,origin) {
86
96
  const {readableFeed,feedEvents,matchesFeed,eventFeeds}=await import('./event-resources.mjs');
87
97
  if(!await readableFeed(env,cat,ctx.caller,feed))throw new Error('Event feed unavailable');
88
98
  if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
89
- const page=await feedEvents(env,feed,{after});
99
+ const page=await feedEvents(env,feed,{after,caller:ctx.caller});
90
100
  return {kind:'untrusted-event-data',events:page.filter(e=>matchesFeed(eventFeeds(env)[feed],e)),next:page.length===100?page.at(-1).seq:null};
91
101
  }),
92
102
  tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
@@ -97,14 +107,31 @@ export function builtins(env,cat,origin) {
97
107
  if(!ctx.caller.owner)throw new Error('Owner only');
98
108
  return (await import('./event-webhooks.mjs')).testWebhook(env,cat,feed,origin);
99
109
  }),
100
- tool('studio_function_runs','Recent function attempts and errors, schedule next due times and trigger diagnostics. Run text is untrusted data. Follow next with before for older runs.','owner',object({name:str,errors:{type:'boolean'},before:{type:'integer',minimum:1}}),async(args,ctx)=>{
101
- if(!ctx.caller.owner)throw new Error('Owner only');
110
+ tool('studio_function_runs','Recent function attempts and errors, schedule next due times and trigger diagnostics. Run text is untrusted data. Follow next with before for older runs.','studio-staff',object({name:str,errors:{type:'boolean'},before:{type:'integer',minimum:1}}),async(args,ctx)=>{
111
+ if(!await allowed('studio-staff',ctx.caller,env))throw new Error('Owner or studio staff only');
102
112
  return (await import('./functions.mjs')).functionRuns(env,args);
103
113
  }),
104
114
  tool('studio_function_replay','Replay retained events for a declaration with replay: true.','owner',object({name:str},['name']),async({name})=>{
105
115
  const {replayFunction,runFunctions}=await import('./functions.mjs');
106
116
  await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
107
117
  }),
118
+ tool('studio_schedule_fire','Locally invoke a named scheduled function at a simulated timestamp.','owner',object({name:str,at:{type:'integer',minimum:0}},['name','at']),async({name,at},ctx)=>{
119
+ if(!env.HOMIE_LOCAL_TEST_SECRET || !['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local dev testing only');
120
+ const {syncFunctions,emitEvent,runFunctions}=await import('./functions.mjs');
121
+ const definitions=await syncFunctions(env), fn=definitions.find(f=>f.name===name&&f.schedule);
122
+ if(!fn)throw new Error(`Unknown scheduled function ${name}; declare it and rebuild dev`);
123
+ const data={function:name,cron:fn.schedule,scheduledTime:at,...(ctx.caller.testScope?{app:`${ctx.caller.testApp}:check:${ctx.caller.testScope}`}:{})};
124
+ const id=await emitEvent(env,fn.event,data);
125
+ await runFunctions(env,cat,origin);
126
+ let run;const end=Date.now()+10000;
127
+ do {
128
+ run=await env.DB.prepare('SELECT outcome,error FROM function_runs WHERE event=?1 AND function=?2 ORDER BY started DESC LIMIT 1').bind(id,name).first();
129
+ if(run && !['running','interrupted'].includes(run.outcome))break;
130
+ await new Promise(resolve=>setTimeout(resolve,50));
131
+ }while(Date.now()<end);
132
+ if(!run || run.outcome!=='complete')throw new Error(`Schedule ${name}: ${run?.error??run?.outcome??'no handler run'}`);
133
+ return {id,name,scheduledTime:at,simulated:true};
134
+ }),
108
135
  tool('studio_function_fire','Fire an event locally through the durable function dispatcher.','owner',object({event:str,id:str,data:{type:'object'}},['event']),async(args)=>{
109
136
  if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
110
137
  const {emitEvent,runFunctions}=await import('./functions.mjs');
package/worker/mcp.mjs CHANGED
@@ -82,7 +82,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
82
82
  const readPage=async(resourceUri,after=0)=>read('events:'+name,async fresh=>{
83
83
  if(!await readableFeed(env,cat,fresh,name))throw new Error('Event resource unavailable');
84
84
  if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
85
- const page=await feedEvents(env,name,{after});
85
+ const page=await feedEvents(env,name,{after,caller:fresh});
86
86
  const events=page.filter(e=>matchesFeed(eventFeeds(env)[name],e));
87
87
  return {contents:[{uri:String(resourceUri),mimeType:'application/json',text:JSON.stringify({kind:'untrusted-event-data',events,next:page.length===100?uri+'?after='+page.at(-1).seq:null})}]};
88
88
  });
@@ -9,7 +9,7 @@ export function linkConfig(env) {
9
9
  export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
10
10
  export function linkSettings(shop) {
11
11
  return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
12
- cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
12
+ cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items, ...(shop.basket ? {basket:shop.basket} : {}) });
13
13
  }
14
14
  export function fullerReason(shop) {
15
15
  if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
@@ -209,7 +209,10 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
209
209
  const kids = audience === 'kids' && !policy.kidsStudio;
210
210
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
211
211
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
212
+ if (raw.basket !== undefined && (!isObj(raw.basket) || typeof raw.basket.name !== "string" || !raw.basket.name.trim() || raw.basket.name.length > 120 || typeof raw.basket.description !== "string" || raw.basket.description.length > 500)) errors.push({at:"basket",message:"A basket needs a name (1–120 characters) and description (up to 500 characters)."});
213
+ if (raw.basket && (raw.automaticTax || till !== "stripe")) errors.push({at:"basket",message:"Saved baskets use Stripe with tax already included in the menu prices."});
212
214
  const shop = {
215
+ ...(isObj(raw.basket) ? {basket:{name:raw.basket.name,description:raw.basket.description}} : {}),
213
216
  v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items,
214
217
  open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
215
218
  };