@homie-rocks/studio 0.40.0 → 0.43.0

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 (84) hide show
  1. package/CHANGELOG.md +54 -1
  2. package/README.md +32 -9
  3. package/app-starters/welcome/app.json +8 -1
  4. package/app-starters/welcome/map/main.json +1 -0
  5. package/app-starters/welcome/src/move.ts +3 -0
  6. package/app-starters/welcome/src/rules.ts +21 -0
  7. package/app-starters/welcome/src/{main.ts → view.ts} +8 -6
  8. package/bin/homie-studio.mjs +11 -3
  9. package/functions/FUNCTIONS.md +99 -0
  10. package/functions/functions.ts +13 -0
  11. package/lib/build.mjs +32 -12
  12. package/lib/cloudflare.mjs +9 -9
  13. package/lib/codex.mjs +1 -1
  14. package/lib/dev.mjs +2 -1
  15. package/lib/functions-build.mjs +36 -0
  16. package/lib/functions-cli.mjs +23 -0
  17. package/lib/mcp-tools.mjs +10 -7
  18. package/lib/mcp.mjs +2 -1
  19. package/lib/port-check.mjs +7 -4
  20. package/lib/port.mjs +2 -2
  21. package/lib/purchase-links.mjs +8 -0
  22. package/lib/rules-build.mjs +6 -6
  23. package/lib/rules-guard.mjs +9 -3
  24. package/lib/scaffold.mjs +58 -15
  25. package/lib/shop.mjs +1 -1
  26. package/lib/studio.mjs +3 -3
  27. package/lib/template-history.json +148 -1
  28. package/lib/tools-build.mjs +2 -0
  29. package/lib/trailer.mjs +7 -3
  30. package/lib/typecheck.mjs +10 -8
  31. package/lib/upgrade.mjs +2 -0
  32. package/lib/worker-build.mjs +8 -20
  33. package/lib/worker-config.mjs +28 -0
  34. package/netplay/NETPLAY.md +8 -0
  35. package/package.json +7 -2
  36. package/rules/host.ts +2 -2
  37. package/rules/view.ts +1 -0
  38. package/shop/SHOP.md +4 -3
  39. package/starters/coin-dash/src/move.ts +2 -0
  40. package/starters/coin-dash/src/rules.ts +2 -0
  41. package/starters/coin-dash/src/view.ts +2 -0
  42. package/starters/ember-vale/src/move.ts +2 -0
  43. package/starters/ember-vale/src/rules.ts +2 -0
  44. package/starters/ember-vale/src/view.ts +2 -0
  45. package/starters/gem-rush/src/move.ts +2 -0
  46. package/starters/gem-rush/src/rules.ts +2 -0
  47. package/starters/gem-rush/src/view.ts +2 -0
  48. package/starters/gem-rush-3d/src/move.ts +2 -0
  49. package/starters/gem-rush-3d/src/rules.ts +2 -0
  50. package/starters/gem-rush-3d/src/view.ts +2 -0
  51. package/starters/hero-rush-3d/src/move.ts +2 -0
  52. package/starters/hero-rush-3d/src/rules.ts +2 -0
  53. package/starters/hero-rush-3d/src/view.ts +2 -0
  54. package/tools/TOOLS.md +42 -2
  55. package/tools/tools.ts +3 -0
  56. package/worker/customer-mcp.mjs +77 -0
  57. package/worker/customer-resources.mjs +53 -0
  58. package/worker/customer-shop.mjs +54 -0
  59. package/worker/extensions.mjs +1 -0
  60. package/worker/functions-schema.mjs +55 -0
  61. package/worker/functions.mjs +93 -0
  62. package/worker/index.mjs +37 -2
  63. package/worker/machine-payments.mjs +1 -1
  64. package/worker/mcp-store.mjs +3 -2
  65. package/worker/mcp-tools.mjs +16 -4
  66. package/worker/mcp.mjs +31 -15
  67. package/worker/office-page.mjs +1 -1
  68. package/worker/players.mjs +5 -2
  69. package/worker/purchase-delivery.mjs +1 -0
  70. package/worker/purchase-hosted-readiness.mjs +0 -1
  71. package/worker/purchase-links.mjs +1 -1
  72. package/worker/purchase-machine.mjs +1 -0
  73. package/worker/purchase-office.mjs +1 -1
  74. package/worker/purchase-owner.mjs +0 -2
  75. package/worker/purchase-reconciliation.mjs +1 -0
  76. package/worker/purchase-routes.mjs +11 -17
  77. package/worker/purchase-store.mjs +1 -0
  78. package/worker/selling/runtime.mjs +4 -1
  79. package/worker/service-run.mjs +25 -0
  80. package/worker/service-terms.mjs +2 -0
  81. package/worker/shop-rules.mjs +9 -8
  82. package/worker/shop.mjs +22 -9
  83. package/worker/tool-webhook.mjs +1 -0
  84. package/worker/version.mjs +1 -1
package/worker/mcp.mjs CHANGED
@@ -1,6 +1,7 @@
1
+ import { selling } from './extensions.mjs';
1
2
  import OAuthProvider from '@cloudflare/workers-oauth-provider';
2
3
  import { createMcpHandler } from 'agents/mcp/server';
3
- import { McpServer, fromJsonSchema } from '@modelcontextprotocol/server';
4
+ import { McpServer, fromJsonSchema, ProtocolError } from '@modelcontextprotocol/server';
4
5
  import { CfWorkerJsonSchemaValidator } from '@modelcontextprotocol/server/validators/cf-worker';
5
6
  import { players } from './players.mjs';
6
7
  import { isOwner, sameOrigin } from './office.mjs';
@@ -20,16 +21,11 @@ async function browserPerson(request,env) {
20
21
  }
21
22
  return null;
22
23
  }
24
+ const paymentInputSchema={type:'object',properties:{buyer:{type:'string',pattern:'^[a-f0-9]{64}$'},claim:{type:'string',pattern:'^[a-f0-9]{64}$'},offerVersion:{type:'string'},checkout:{type:'boolean'}},additionalProperties:false};
23
25
  export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
24
26
  const url=new URL(request.url), origin=url.origin, path=url.pathname;
25
27
  if(!env.DB)return json({error:'studio database required'},503);
26
28
  if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
27
- // Cap all protocol bodies before handing them to a library or a handler.
28
- if(request.body){
29
- const reader=request.body.getReader(),chunks=[];let size=0;
30
- for(;;){const {done,value}=await reader.read();if(done)break;size+=value.length;if(size>65536){await reader.cancel();return json({error:'request too large'},413);}chunks.push(value);}
31
- const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}request=new Request(request,{body:bytes});
32
- }
33
29
  if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
34
30
  const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
35
31
  const api=async(request,env,ctx)=>{
@@ -40,7 +36,8 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
40
36
  const body=await request.clone().json().catch(()=>null);
41
37
  if(body?.method==='tools/call') {
42
38
  const tool=tools.find(t=>t.name===body.params?.name);
43
- if(tool&&!validator.getValidator(tool.inputSchema)(body.params?.arguments??{}).valid)await audit(env,caller,tool.name,'invalid');
39
+ const schema=tool?.price?{...tool.inputSchema,properties:{...tool.inputSchema.properties,_payment:paymentInputSchema}}:tool?.inputSchema;
40
+ if(tool&&!validator.getValidator(schema)(body.params?.arguments??{}).valid)await audit(env,caller,tool.name,'invalid');
44
41
  if(!tool)await audit(env,caller,'unknown-tool','denied');
45
42
  }
46
43
  }
@@ -50,11 +47,13 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
50
47
  };
51
48
  const read=async(name,handler)=>{
52
49
  let outcome='error';
53
- try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);if(JSON.stringify(result).length>65536)throw new Error('Result too large');outcome='ok';return result;}
50
+ try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);outcome='ok';return result;}
54
51
  finally{await audit(env,caller,name,outcome);}
55
52
  };
56
53
  const server=async()=>{
57
- const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator});
54
+ const capabilities=await selling.machineCapabilities(env);
55
+ const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
56
+ const dispatch=new Map();
58
57
  for (const meta of cat.games.filter(g=>g.kind==='app' && g.records?.persist)) {
59
58
  for (const [role,declared] of Object.entries(meta.roles??{})) {
60
59
  if (declared.signIn && !await allowed({app:meta.id,role},caller,env)) continue;
@@ -69,6 +68,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
69
68
  }
70
69
  }
71
70
  for(const tool of tools){
71
+ if (!await allowed(tool.audience,caller,env)) continue;
72
72
  if (tool.kind==='prompt') {
73
73
  if (!await allowed(tool.audience,caller,env)) continue;
74
74
  s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
@@ -77,21 +77,37 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
77
77
  return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
78
78
  }));continue;
79
79
  }
80
- s.registerTool(tool.name,{description:tool.description,inputSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>{
80
+ const schema=tool.price?{...tool.inputSchema,properties:{...tool.inputSchema.properties,_payment:paymentInputSchema}}:tool.inputSchema;
81
+ const handler=async(args,extra)=>{
81
82
  let outcome='error';
82
83
  try{
83
84
  // Recheck inside dispatch, even when a connection listed tools earlier.
84
85
  const fresh=await currentCaller(env,ctx.props);
85
86
  if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
86
87
  try{await rate(fresh);}catch(error){outcome='limited';throw error;}
87
- const value=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
88
+ const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
89
+ if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
90
+ if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
91
+ const value=await tool.handler(args,{...context,request});
88
92
  const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
89
- if(text.length>65536)throw new Error('Result too large; narrow your request');
90
93
  outcome='ok';return {content:[{type:'text',text}]};
91
- }catch(error){return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
94
+ }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
92
95
  finally{await audit(env,caller,tool.name,outcome);}
93
- });
96
+ };
97
+ s.registerTool(tool.name,{description:tool.description,inputSchema:fromJsonSchema(schema,validator)},handler);
98
+ dispatch.set(tool.name,{schema,handler});
94
99
  }
100
+ // Payment binding errors are JSON-RPC errors, not tool failures. The SDK's
101
+ // convenience dispatcher turns arbitrary errors into isError results; use its
102
+ // public protocol handler to preserve mppx's standard -32042 challenge.
103
+ s.server.setRequestHandler('tools/call',async(input,extra)=>{
104
+ const entry=dispatch.get(input.params.name);
105
+ if(!entry)return {isError:true,content:[{type:'text',text:'Permission denied or unknown tool'}]};
106
+ const args=input.params.arguments??{};
107
+ if(!validator.getValidator(entry.schema)(args).valid)return {isError:true,content:[{type:'text',text:'Invalid arguments'}]};
108
+ try{return await entry.handler(args,{...extra,_meta:input.params._meta});}
109
+ catch(error){if(error?.code===-32042)throw new ProtocolError(error.code,error.message,error.data);throw error;}
110
+ });
95
111
  return s;
96
112
  };
97
113
  return createMcpHandler(server)(request,env,ctx);
@@ -937,7 +937,7 @@ export async function officePage(cat, headers = {}) {
937
937
  const name = cat.studio?.name ?? 'Studio';
938
938
  const options = (cat.games ?? []).map((g) => `<option value="${esc(g.id)}">every room of ${esc(g.name)}</option>`).join('');
939
939
  const body = `<header class="top"><h1>${esc(name)} <small>Office</small></h1>
940
- <div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a><a href="/_studio/office/connections">AI connections</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
940
+ <div class="links"><span class="live"><i class="dot" id="livedot"></i><span id="live">…</span></span><a href="/_studio/stats">Stats</a><a href="/_studio/office/connections">AI connections</a><a href="/_studio/office/functions">Functions</a>${cat.shop ? '<a href="/_studio/office/shop">Shop</a>' : ''}
941
941
  <form method="post" action="/_studio/signout" style="display:inline"><button class="ghost small" type="submit">Sign out</button></form></div></header>
942
942
  <form class="bar" id="announce-all"><label for="announce-text">Announce</label><input type="text" id="announce-text" maxlength="280" placeholder="A line every player sees, in the game" autocomplete="off">
943
943
  <select id="announce-scope" aria-label="Who sees it"><option value="">every room of every game</option>${options}</select><button type="submit">Send</button>
@@ -1,3 +1,4 @@
1
+ import { toolIdentity } from './tool-identity.mjs';
1
2
  /**
2
3
  * PLAYERS — accounts on ONE studio, passkey first, and the cloud saves a persistent game keeps (saves/SAVES.md).
3
4
  *
@@ -236,8 +237,8 @@ async function sessionOf(request, env, url = new URL(request.url)) {
236
237
  /** A guest for a request with no player yet (the first save). Refused past the address's or the day's limit. */
237
238
  async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlayersPerHour, counter = 'new') {
238
239
  const ip = addressOf(request);
239
- if (limited(`${counter}:${ip}`, addressLimit, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
240
- const capped = await dailyCapReached(env);
240
+ if (addressLimit !== null && limited(`${counter}:${ip}`, addressLimit, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
241
+ const capped = (counter !== 'shop-new' || env.PLAYER_LIMIT_DAILY !== undefined) && await dailyCapReached(env);
241
242
  if (capped) return { error: fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow') };
242
243
  const id = newPlayerId();
243
244
  const now = Date.now();
@@ -383,6 +384,8 @@ export const players = Object.freeze({
383
384
  },
384
385
  /** The request's signed-in player ({ id, name, guest, owner }), or null. */
385
386
  async of(request, env) {
387
+ const identity=toolIdentity(request);
388
+ if(identity?.id && !identity.id.startsWith('office-')) { const p=await env.DB.prepare('SELECT id,name,guest,owner FROM players WHERE id=?1').bind(identity.id).first();return p?{...p,guest:Boolean(p.guest),owner:Boolean(p.owner)}:null; }
386
389
  try { const s = await sessionOf(request, env); return s ? { id: s.player.id, name: s.player.name, guest: s.player.guest, owner: s.player.owner } : null; } catch { return null; }
387
390
  },
388
391
  /** Shop guest creation uses the studio's new-guest address rate and the daily player limit. */
@@ -55,6 +55,7 @@ export async function grantPurchase(env, origin, o, b = {}) {
55
55
  : null;
56
56
  if (!rights || !rights.active)
57
57
  return fail(402, `Purchase is ${coreOrder.state}; delivery is unavailable`);
58
+ await resourceKind(o.resource_kind).fulfill?.(env,origin,o);
58
59
  const key = await signingKey(env);
59
60
  const now = Math.floor(Date.now() / 1000);
60
61
  const claims = {
@@ -8,7 +8,6 @@ import { recurringReady } from './purchase-store.mjs';
8
8
  export async function hostedReadiness(env, catalogue, recurring = false) {
9
9
  const missing = [];
10
10
  if (!env.DB || env.HOMIE_PREVIEW === '1') missing.push('Studio purchases need the deployed database');
11
- if (!env.PURCHASE_RATE_LIMITER) missing.push('Deploy the purchase rate limiting binding');
12
11
  try { await signingKey(env); } catch { missing.push('Create a valid purchase signing key'); }
13
12
  if ((!hasStripeKey(env) && !purchaseLinks(env)) || !WEBHOOK_SECRET_SHAPE.test(env.STRIPE_WEBHOOK_SECRET ?? '') || !['stripe', 'stripe-managed'].includes(catalogue.shop?.purchasesTill ?? catalogue.shop?.till)) missing.push('Connect this studio shop to Stripe first');
14
13
  if (recurring && hasStripeKey(env) && !(await recurringReady(env))) missing.push('Reconnect the shop to verify subscription permissions and webhook events before selling recurring resources');
@@ -5,5 +5,5 @@ export function purchaseLinks(env) {
5
5
  try { const c = JSON.parse(env.PURCHASE_LINKS ?? 'null'); return c?.v === 1 && c.mode === linkConfig(env)?.mode && Array.isArray(c.offers) ? c : null; } catch { return null; }
6
6
  }
7
7
  export function purchaseLink(env, o) {
8
- return purchaseLinks(env)?.offers.find((link) => (link.quantity ?? 1) === o.quantity && link.kind === o.resource_kind && link.resource === o.resource_id && link.version === JSON.parse(o.manifest).version && link.offer === canonicalJson(JSON.parse(o.offer)) && paymentLinkURL(link.url));
8
+ return purchaseLinks(env)?.offers.find((link) => (link.quantity ?? 1) === o.quantity && link.kind === o.resource_kind && link.resource === (o.resource_kind==='service'?JSON.parse(o.manifest).tool:o.resource_id) && link.version === JSON.parse(o.manifest).version && link.offer === canonicalJson(JSON.parse(o.offer)) && paymentLinkURL(link.url));
9
9
  }
@@ -154,6 +154,7 @@ export async function machineResource(
154
154
  .first();
155
155
  o = await byId(env, o.id);
156
156
  if (["paid", "fulfilled", "disputed"].includes(o.status)) {
157
+ if(mcp && core?.transport==='checkout') {const result=await response();return result instanceof Response?result:machineMcpDelivery(result,dependencies);}
157
158
  const recorded = stored ? null : await env.DB.prepare("SELECT payload FROM purchase_jobs WHERE order_id = ?1 ORDER BY updated_at DESC LIMIT 1").bind(o.id).first();
158
159
  const recording = recorded && JSON.parse(recorded.payload);
159
160
  const challengeId = recording?.params?.metadata?.mpp_challenge_id;
@@ -27,7 +27,7 @@ export async function extendOffice(env, cat, origin, out) {
27
27
  try {
28
28
  out.purchases = await purchaseReadiness(env, origin, cat);
29
29
  const rows = (await env.DB.prepare('SELECT o.*, r.manifest AS manifest FROM purchase_orders o JOIN resource_snapshots r ON r.hash=o.manifest_hash ORDER BY o.created_at DESC LIMIT 100').all()).results ?? [];
30
- for (const r of rows) out.orders.push({ id: r.id, status: r.status, resourceKind: r.resource_kind, resource: r.resource_id, name: JSON.parse(r.manifest).name, shown: money(r.amount,r.currency), amount:r.amount, currency:r.currency, createdAt:r.created_at, mode:r.mode, player:{name:r.buyer_label}, stripe:r.payment ? dashboardLink(r.mode,'payment',r.payment):null, refundable:hasStripeKey(env) && ['paid','fulfilled'].includes(r.status), manualRefund:await manualRefundDetails(env,r) });
30
+ for (const r of rows.filter(r=>!out.orders.some(o=>o.id===r.id))) out.orders.push({ id: r.id, status: r.status, resourceKind: r.resource_kind, resource: r.resource_id, name: JSON.parse(r.manifest).name, shown: money(r.amount,r.currency), amount:r.amount, currency:r.currency, createdAt:r.created_at, mode:r.mode, player:{name:r.buyer_label}, stripe:r.payment ? dashboardLink(r.mode,'payment',r.payment):null, refundable:hasStripeKey(env) && ['paid','fulfilled'].includes(r.status), manualRefund:await manualRefundDetails(env,r) });
31
31
  out.orders.sort((a,b)=>b.createdAt-a.createdAt);
32
32
  out.paymentRecovery = (await env.DB.prepare(`SELECT id AS request_key, order_id, state, payment, error, attempts, updated_at FROM purchase_jobs WHERE ${jobsSQL('visible')} ORDER BY updated_at`).all()).results ?? [];
33
33
  out.settlementRecovery = (await env.DB.prepare('SELECT order_id, network, state, reference, error, created_at, updated_at FROM purchase_settlements ORDER BY created_at').all()).results ?? [];
@@ -109,8 +109,6 @@ export async function purchaseReadiness(env, origin, catalogue = {}) {
109
109
  missing.push(
110
110
  "Reconnect the shop to verify subscription permissions and webhook events",
111
111
  );
112
- if (!env.PURCHASE_RATE_LIMITER)
113
- missing.push("Deploy the Cloudflare purchase rate limiting binding");
114
112
  if (!env.PURCHASE_SIGNING_KEYS)
115
113
  missing.push("Create the separate purchase signing key");
116
114
  const machine = await machineCapabilities(env);
@@ -298,6 +298,7 @@ export async function reconcileOneTime(env, o) {
298
298
  export async function reconcileOrder(env, o) {
299
299
  if (o.subscription && hasStripeKey(env) && !await linkOf(env,o.id)) await reconcileSubscription(env, o);
300
300
  else await reconcileOneTime(env, o);
301
+ if(o.resource_kind==='cart' && o.payment) await (await import('./shop.mjs')).syncRefunds(env,o.payment);
301
302
  }
302
303
 
303
304
  /** Called only AFTER the existing shop webhook signature/mode check. Return null for player orders. */
@@ -136,19 +136,7 @@ export async function purchaseRoutes(
136
136
  if (env.HOMIE_PREVIEW === "1" || !env.DB)
137
137
  return fail(503, "Studio purchases need the deployed database");
138
138
  try {
139
- if (!env.PURCHASE_RATE_LIMITER)
140
- return fail(
141
- 503,
142
- "Deploy the Cloudflare purchase rate limiting binding before selling",
143
- );
144
- if (
145
- !(
146
- await env.PURCHASE_RATE_LIMITER.limit({
147
- key: `${request.headers.get("cf-connecting-ip") ?? "unknown"}:${request.method}`,
148
- })
149
- ).success
150
- )
151
- return fail(429, "Too many purchase requests. Try again shortly");
139
+ if (env.PURCHASE_RATE_LIMITER && !(await env.PURCHASE_RATE_LIMITER.limit({key: `${request.headers.get('cf-connecting-ip') ?? 'unknown'}:${request.method}`})).success) return fail(429, 'Studio purchase rate limit reached');
152
140
  if (path === "/api/purchases/mcp") {
153
141
  const { purchaseMcp } = await import("./purchase-mcp.mjs");
154
142
  return purchaseMcp(request, env, url, cat);
@@ -193,7 +181,7 @@ export async function purchaseRoutes(
193
181
  ].includes(path) &&
194
182
  request.method === "POST"
195
183
  ) {
196
- return await claimedPurchase(request, env, url, path);
184
+ return await claimedPurchase(request, env, url, path, cat);
197
185
  }
198
186
  return fail(404, "No such studio purchase route");
199
187
  } catch (error) {
@@ -484,7 +472,7 @@ async function verifyPurchaseEvidence(request, env, url) {
484
472
  });
485
473
  }
486
474
 
487
- async function claimedPurchase(request, env, url, path) {
475
+ async function claimedPurchase(request, env, url, path, cat) {
488
476
  const claim = bearer(request);
489
477
  if (!/^[a-f0-9]{64}$/.test(claim ?? ""))
490
478
  return fail(401, "Purchase claim required");
@@ -514,6 +502,12 @@ async function claimedPurchase(request, env, url, path) {
514
502
  await reconcileSubscription(env, o);
515
503
  o = await byId(env, o.id);
516
504
  }
505
+ if (o.resource_kind === 'cart') {
506
+ const { refundableShopOrder, shopOf } = await import('./shop.mjs');
507
+ const { orderById } = await import('./shop-store.mjs');
508
+ if (!await refundableShopOrder(env, await orderById(env, o.id), shopOf(cat)))
509
+ return fail(403, 'Ask the studio for this refund. Tips and used items follow the studio refund settings.');
510
+ }
517
511
  const days = termsOf(o).refundWindowDays;
518
512
  let paidAt = Number(o.paid_at);
519
513
  if (o.subscription && o.payment) {
@@ -536,12 +530,12 @@ async function claimedPurchase(request, env, url, path) {
536
530
  }
537
531
  if (o.paid_at && !o.payment) return fail(409, 'Stripe has not recorded this chain payment. Contact the seller for a direct refund to the original payer.');
538
532
  if (!o.paid_at) return fail(409, "This order is not paid; there is no payment to refund");
539
- if (
533
+ if (o.resource_kind !== 'cart' && (
540
534
  !Number.isFinite(days) ||
541
535
  days <= 0 ||
542
536
  !paidAt ||
543
537
  Date.now() > paidAt + days * 86400000
544
- )
538
+ ))
545
539
  return fail(
546
540
  403,
547
541
  "This purchase is outside the seller self-service refund window; contact the seller with your receipt",
@@ -75,6 +75,7 @@ export async function privateRelease(env, kind, id, version) {
75
75
  export async function listedRelease(env, origin, kind, id, version) {
76
76
  if (await env.DB.prepare("SELECT 1 FROM resource_retirements WHERE resource = ?1").bind(`${shopMode(env)}:${kind}:${id}`).first()) return null;
77
77
  const adapter = resourceKind(kind);
78
+ if (adapter.listed) return adapter.listed(env,id,version,origin);
78
79
  const listed = (await adapter.list(env,origin)).find((r)=>r.id===id && r.version===version);
79
80
  if (!listed) return null;
80
81
  const resource = await privateRelease(env,kind,id,version);
@@ -1,3 +1,5 @@
1
+ import {pruneOffers} from '../customer-resources.mjs';
2
+ import { customerTools, paidTool } from '../customer-mcp.mjs';
1
3
  import { productDescription, entryDescription, discoveryRoutes } from '../parts-purchase-catalogue.mjs';
2
4
  import { officeScript } from '../purchase-office-script.mjs';
3
5
  import { officeRoutes } from '../purchase-office-routes.mjs';
@@ -27,10 +29,11 @@ import { paidFile } from '../parts-protected.mjs';
27
29
  import { priceWords } from '../purchase-pricing.mjs';
28
30
  import { purchaseDiscovery } from '../purchase-discovery.mjs';
29
31
  import { testPaymentExchange } from '../payment-test-exchange.mjs';
30
- useSelling({ enabled: true, productDescription, entryDescription, discoveryRoutes, officeScript, officeRoutes, refundOrder, extendOffice, testPaymentExchange, jobsSQL, manualRefundDetails, confirmManualRefund, listOffers, orderFact, resourceOrder, currentPurchaseOrder, purchasePaymentEvent, refundPurchasePayment, purchaseReadiness, purchaseRoutes, recordOrderState, authorizePurchaseTest, expirePurchaseCheckouts, reissuePurchaseClaim, retireResource, reconcileRecordings, machineCapabilities, paidFile, priceWords, purchaseDiscovery });
32
+ useSelling({ enabled: true, customerTools, paidTool, productDescription, entryDescription, discoveryRoutes, officeScript, officeRoutes, refundOrder, extendOffice, testPaymentExchange, jobsSQL, manualRefundDetails, confirmManualRefund, listOffers, orderFact, resourceOrder, currentPurchaseOrder, purchasePaymentEvent, refundPurchasePayment, purchaseReadiness, purchaseRoutes, recordOrderState, authorizePurchaseTest, expirePurchaseCheckouts, reissuePurchaseClaim, retireResource, reconcileRecordings, machineCapabilities, paidFile, priceWords, purchaseDiscovery });
31
33
  export default { ...worker,
32
34
  async scheduled(controller, env, ctx) {
33
35
  await worker.scheduled(controller, env, ctx);
36
+ if(env.DB)await pruneOffers(env);
34
37
  if (!env.PURCHASE_MACHINE_PAYMENTS || !env.DB) return;
35
38
  const { refreshMachineCapabilities } = await import('../payment-capabilities.mjs');
36
39
  const errors = [];
@@ -0,0 +1,25 @@
1
+ /** A running service is never executed again, even if its Worker disappears.
2
+ * Heartbeats follow active work; an abandoned run goes to refund recovery. */
3
+ export async function serviceResult(env,id,handler,refund,{now=Date.now,leaseMs=60000}={}) {
4
+ await env.DB.prepare(`INSERT OR IGNORE INTO service_results(order_id,state,due) VALUES(?1,'pending',0)`).bind(id).run();
5
+ const lease=crypto.randomUUID();
6
+ const claimed=await env.DB.prepare(`UPDATE service_results SET state='running',lease=?2,due=?3 WHERE order_id=?1 AND state='pending'`).bind(id,lease,now()+leaseMs).run();
7
+ const fail=async()=>env.DB.prepare(`UPDATE service_results SET state='refund-pending' WHERE order_id=?1 AND state='running'`).bind(id).run();
8
+ if(claimed.meta.changes) {
9
+ let renewal=Promise.resolve();
10
+ const timer=setInterval(()=>{renewal=renewal.then(()=>env.DB.prepare(`UPDATE service_results SET due=?3 WHERE order_id=?1 AND lease=?2 AND state='running'`).bind(id,lease,now()+leaseMs).run()).catch(()=>{});},leaseMs/3);
11
+ try {
12
+ const value=await handler();
13
+ await env.DB.prepare(`UPDATE service_results SET state='complete',result=?3 WHERE order_id=?1 AND lease=?2 AND state='running'`).bind(id,lease,JSON.stringify(value??null)).run();
14
+ } catch {await fail();}
15
+ finally{clearInterval(timer);await renewal;}
16
+ }
17
+ let saved=await env.DB.prepare('SELECT state,result,due FROM service_results WHERE order_id=?1').bind(id).first();
18
+ if(saved.state==='running' && saved.due<=now()){await fail();saved.state='refund-pending';}
19
+ if(saved.state==='refund-pending') {
20
+ let outcome;try{outcome=await refund();}catch{outcome={ok:false};}
21
+ if(outcome.ok && !outcome.pending){await env.DB.prepare(`UPDATE service_results SET state='refunded',result=?2 WHERE order_id=?1 AND state='refund-pending'`).bind(id,JSON.stringify({message:'The tool failed. Your payment was automatically refunded.'})).run();}
22
+ saved=await env.DB.prepare('SELECT state,result,due FROM service_results WHERE order_id=?1').bind(id).first();
23
+ }
24
+ return {order:id,state:saved.state,...(saved.state==='complete'?{data:JSON.parse(saved.result)}:saved.state==='refunded'?JSON.parse(saved.result):saved.state==='refund-pending'?{message:'The tool failed. An automatic refund is pending; retry this same claim to check it. The tool will not run again.'}:{retry:'Retry this same claim; the service is running.'})};
25
+ }
@@ -0,0 +1,2 @@
1
+ /** Shared immutable terms for priced tools and their Stripe Payment Links. */
2
+ export const serviceTerms = (price) => ({billing:'one-time',scope:'service',updates:'none',source:false,commercialUse:true,transferable:false,onRefund:'terminate',onExpiry:'terminate',refund:'Ask the studio for a refund.',...price});
@@ -128,14 +128,15 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
128
128
  const requestBytes = optionalNumber(raw.requestBytes, 'requestBytes');
129
129
  const refundDays = optionalNumber(raw.refundDays, 'refundDays');
130
130
  const cap = optionalNumber(raw.capPerPlayerMonth, 'capPerPlayerMonth');
131
- // 600 new buyers per hour allows a school or venue to arrive together without disabling flood protection.
132
- const guestBuyersPerAddressPerHour = numeric(raw.guestBuyersPerAddressPerHour ?? 600);
133
- if (!Number.isSafeInteger(guestBuyersPerAddressPerHour) || guestBuyersPerAddressPerHour < 1) err('guestBuyersPerAddressPerHour', 'use a positive whole number for new guest flood protection');
134
- const purchaseAttemptsPerMinute = numeric(raw.purchaseAttemptsPerMinute ?? 6);
135
- // A school or venue can share an address; its aggregate bucket must allow many independent accounts.
136
- const purchaseAttemptsPerAddressPerMinute = numeric(raw.purchaseAttemptsPerAddressPerMinute ?? 600);
137
- if (!Number.isSafeInteger(purchaseAttemptsPerAddressPerMinute) || purchaseAttemptsPerAddressPerMinute < 1) err('purchaseAttemptsPerAddressPerMinute', 'use a positive whole number for address flood protection');
138
- if (!Number.isSafeInteger(purchaseAttemptsPerMinute) || purchaseAttemptsPerMinute < 1) err('purchaseAttemptsPerMinute', 'use a positive whole number for the studio account rate limit');
131
+ const ratePolicy = key => {
132
+ if(raw[key] === undefined || raw[key] === null)return null;
133
+ const value=numeric(raw[key]);
134
+ if(!Number.isSafeInteger(value)||value<1)err(key,'use a positive whole number, or omit the rate policy');
135
+ return value;
136
+ };
137
+ const guestBuyersPerAddressPerHour = ratePolicy('guestBuyersPerAddressPerHour');
138
+ const purchaseAttemptsPerMinute = ratePolicy('purchaseAttemptsPerMinute');
139
+ const purchaseAttemptsPerAddressPerMinute = ratePolicy('purchaseAttemptsPerAddressPerMinute');
139
140
 
140
141
  for (const key of ['automaticTax', 'referralNewPlayersOnly']) if (raw[key] !== undefined && typeof raw[key] !== 'boolean') err(key, 'use a boolean');
141
142
  const items = [];
package/worker/shop.mjs CHANGED
@@ -556,8 +556,8 @@ const WAY_WORDS = Object.freeze({
556
556
  });
557
557
 
558
558
  function purchaseLimited(request, shop, player, kind) {
559
- const account = player && limited(`${kind}:account:${player}`, shop.purchaseAttemptsPerMinute, 60_000);
560
- const address = limited(`${kind}:address:${addressOf(request)}`, shop.purchaseAttemptsPerAddressPerMinute, 60_000);
559
+ const account = player && shop.purchaseAttemptsPerMinute !== null && limited(`${kind}:account:${player}`, shop.purchaseAttemptsPerMinute, 60_000);
560
+ const address = shop.purchaseAttemptsPerAddressPerMinute !== null && limited(`${kind}:address:${addressOf(request)}`, shop.purchaseAttemptsPerAddressPerMinute, 60_000);
561
561
  return account || address;
562
562
  }
563
563
 
@@ -707,6 +707,18 @@ function refundableNow(order, owns, shop, now = Date.now()) {
707
707
  return shop.policy.refundUsedItems || !owns.some((o) => o.item === order.item && o.used);
708
708
  }
709
709
 
710
+ /** Shared authorization for web and claim refunds, using persisted grants and line snapshots. */
711
+ export async function refundableShopOrder(env, order, shop, lines = null) {
712
+ if (!order || !shop) return false;
713
+ lines ??= await orderLines(env, order.id);
714
+ if (!lines.length) return false;
715
+ for (const line of lines) {
716
+ const used = await lineWasUsed(env, order.id, line);
717
+ if (!refundableNow({ ...lineView(line), paidAt: order.paid_at }, used ? [{ item: line.item, used: true }] : [], shop)) return false;
718
+ }
719
+ return true;
720
+ }
721
+
710
722
  /** A player's own refund follows the studio window and used-item setting, excluding tips. */
711
723
  async function selfRefundRoute(request, env, shop, ready) {
712
724
  const b = await bodyOf(request, requestSize(shop));
@@ -717,10 +729,7 @@ async function selfRefundRoute(request, env, shop, ready) {
717
729
  if (!o || o.player !== p.id) return fail(404, 'order', 'no such order on this account');
718
730
  const lines = (await orderLines(env, o.id)).filter((l) => !b.body.line || l.id === b.body.line);
719
731
  if (!lines.length) return fail(404, 'line', 'no such line on this order');
720
- for (const line of lines) {
721
- const used = await lineWasUsed(env, o.id, line);
722
- if (!refundableNow({ ...lineView(line), paidAt: o.paid_at }, used ? [{ item: line.item, used: true }] : [], shop)) return fail(403, 'not-refundable', 'Ask the studio for this refund. Tips are not refundable by the player; items follow the studio refund settings.');
723
- }
732
+ if (!await refundableShopOrder(env, o, shop, lines)) return fail(403, 'not-refundable', 'Ask the studio for this refund. Tips are not refundable by the player; items follow the studio refund settings.');
724
733
  if (Number(o.amount) !== 0 && !hasStripeKey(env)) return fail(409, 'ask-studio', 'Ask the studio to refund this payment in Stripe.');
725
734
  const r = await refundOrder(env, o, { reason: 'requested_by_customer', by: 'player', line: b.body.line });
726
735
  if (r.held) return json({ ...r, error: r.error === 'pending' ? 'pending' : 'held', message: r.error === 'pending' ? r.message : 'The studio approves this refund in Stripe first. Your money comes back once they do, and the item leaves your account then.' }, 202);
@@ -743,7 +752,11 @@ async function hook(request, env, cat, shop, ready) {
743
752
  if (selling.enabled) {
744
753
  if (typeof ev.livemode === 'boolean' && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
745
754
  const purchase = await selling.purchasePaymentEvent(env, ev);
746
- if (purchase !== null) { await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run(); return json({ ok: true, did: purchase }); }
755
+ if (purchase !== null) {
756
+ // Resource carts share the web shop's grants and refund bookkeeping.
757
+ const payment = typeof obj.payment_intent === 'string' ? obj.payment_intent : obj.payment_intent?.id;
758
+ if (payment) await syncRefunds(env, payment);
759
+ await env.DB.prepare('INSERT INTO shop_events (id, type, at) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING').bind(ev.id, ev.type, Date.now()).run(); return json({ ok: true, did: purchase }); }
747
760
  }
748
761
  if (!needed.includes(ev.type)) return json({ ok: true, did: 'ignored' });
749
762
  if (typeof ev.livemode === 'boolean' && (hasStripeKey(env) || linkConfig(env)) && ev.livemode !== (shopMode(env) === 'live')) return json({ ok: true, ignored: 'mode' });
@@ -864,7 +877,7 @@ async function bindLinkSession(env, session, mode) {
864
877
  }
865
878
 
866
879
  /** A paid checkout: the order is paid, its entitlements granted, a referral line written. Idempotent. */
867
- async function paid(env, shop, session, paidAt = Date.now()) {
880
+ export async function paid(env, shop, session, paidAt = Date.now()) {
868
881
  const o = await orderBySession(env, session.id);
869
882
  if (!o) return 'unknown-order';
870
883
  // The session must be the one this order opened, for this player and this price.
@@ -950,7 +963,7 @@ async function paid(env, shop, session, paidAt = Date.now()) {
950
963
  * https://docs.stripe.com/api/refunds/object#refund_object-status
951
964
  * https://docs.stripe.com/api/refunds/list
952
965
  */
953
- async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
966
+ export async function syncRefunds(env, payment, freeOrder = null, freeLines = []) {
954
967
  const o = freeOrder ?? await orderByPayment(env, payment);
955
968
  if (!o) return 'unknown-order';
956
969
  const all = await orderLines(env, o.id);
@@ -6,6 +6,7 @@ export async function toolWebhook(request,env,{cat,definitions}) {
6
6
  const name=new URL(request.url).pathname.split('/').pop(),tool=definitions.find(t=>t.name===name&&t.webhook);
7
7
  const fail=(status)=>Response.json({ok:false},{status});
8
8
  if(!tool||request.method!=='POST')return fail(404);
9
+ if(tool.price)return Response.json({ok:false,error:'Use /mcp to approve and pay for this tool'},{status:402});
9
10
  const timestamp=request.headers.get('x-studio-timestamp')??'',delivery=request.headers.get('x-studio-delivery')??'',signature=request.headers.get('x-studio-signature')??'';
10
11
  if(!/^\d{10}$/.test(timestamp)||Math.abs(Date.now()/1000-Number(timestamp))>300||!/^[a-zA-Z0-9_-]{8,100}$/.test(delivery)||!/^[a-f0-9]{64}$/.test(signature))return fail(401);
11
12
  const secret=env[tool.webhook.secret];if(typeof secret!=='string'||secret.length<32)return fail(503);
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.40.0';
2
+ export const STUDIO_VERSION_TAG = '0.43.0';