@homie-rocks/studio 0.39.0 → 0.42.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 (83) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +11 -0
  3. package/bin/homie-studio.mjs +8 -0
  4. package/functions/FUNCTIONS.md +99 -0
  5. package/functions/functions.ts +13 -0
  6. package/lib/build.mjs +2 -0
  7. package/lib/cloudflare.mjs +9 -9
  8. package/lib/dev.mjs +2 -1
  9. package/lib/functions-build.mjs +36 -0
  10. package/lib/functions-cli.mjs +23 -0
  11. package/lib/port-check.mjs +6 -3
  12. package/lib/purchase-links.mjs +8 -0
  13. package/lib/rules-build.mjs +8 -1
  14. package/lib/rules-check-worker.mjs +3 -3
  15. package/lib/rules-check.mjs +3 -1
  16. package/lib/scaffold.mjs +16 -3
  17. package/lib/shop.mjs +1 -1
  18. package/lib/template-history.json +85 -0
  19. package/lib/tools-build.mjs +2 -0
  20. package/lib/upgrade.mjs +2 -0
  21. package/lib/worker-build.mjs +8 -20
  22. package/lib/worker-config.mjs +28 -0
  23. package/netplay/NETPLAY.md +74 -2
  24. package/package.json +7 -2
  25. package/rules/collision.ts +115 -0
  26. package/rules/core.ts +65 -25
  27. package/rules/host.ts +5 -1
  28. package/rules/math.ts +15 -5
  29. package/rules/rules.ts +33 -10
  30. package/rules/types.ts +2 -2
  31. package/rules/view.ts +126 -32
  32. package/shop/SHOP.md +4 -3
  33. package/starters/ember-vale/game.json +16 -4
  34. package/starters/ember-vale/map/main.json +66 -0
  35. package/starters/ember-vale/src/move.ts +31 -0
  36. package/starters/ember-vale/src/rules.ts +244 -0
  37. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  38. package/starters/ember-vale/tunables.json +100 -11
  39. package/starters/gem-rush/src/move.ts +9 -1
  40. package/starters/gem-rush/src/view.ts +2 -1
  41. package/starters/gem-rush-3d/game.json +23 -5
  42. package/starters/gem-rush-3d/map/main.json +153 -0
  43. package/starters/gem-rush-3d/src/move.ts +37 -0
  44. package/starters/gem-rush-3d/src/rules.ts +133 -0
  45. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  46. package/starters/gem-rush-3d/tunables.json +98 -11
  47. package/starters/hero-rush-3d/game.json +23 -5
  48. package/starters/hero-rush-3d/map/main.json +153 -0
  49. package/starters/hero-rush-3d/src/move.ts +49 -0
  50. package/starters/hero-rush-3d/src/rules.ts +200 -0
  51. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  52. package/starters/hero-rush-3d/tunables.json +260 -30
  53. package/tools/TOOLS.md +42 -2
  54. package/tools/tools.ts +3 -0
  55. package/worker/customer-mcp.mjs +77 -0
  56. package/worker/customer-resources.mjs +53 -0
  57. package/worker/customer-shop.mjs +54 -0
  58. package/worker/extensions.mjs +1 -0
  59. package/worker/functions-schema.mjs +55 -0
  60. package/worker/functions.mjs +93 -0
  61. package/worker/index.mjs +37 -2
  62. package/worker/machine-payments.mjs +1 -1
  63. package/worker/mcp-store.mjs +3 -2
  64. package/worker/mcp-tools.mjs +16 -4
  65. package/worker/mcp.mjs +31 -15
  66. package/worker/office-page.mjs +1 -1
  67. package/worker/players.mjs +5 -2
  68. package/worker/purchase-delivery.mjs +1 -0
  69. package/worker/purchase-hosted-readiness.mjs +0 -1
  70. package/worker/purchase-links.mjs +1 -1
  71. package/worker/purchase-machine.mjs +1 -0
  72. package/worker/purchase-office.mjs +1 -1
  73. package/worker/purchase-owner.mjs +0 -2
  74. package/worker/purchase-reconciliation.mjs +1 -0
  75. package/worker/purchase-routes.mjs +11 -17
  76. package/worker/purchase-store.mjs +1 -0
  77. package/worker/selling/runtime.mjs +4 -1
  78. package/worker/service-run.mjs +25 -0
  79. package/worker/service-terms.mjs +2 -0
  80. package/worker/shop-rules.mjs +9 -8
  81. package/worker/shop.mjs +22 -9
  82. package/worker/tool-webhook.mjs +1 -0
  83. package/worker/version.mjs +1 -1
@@ -0,0 +1,93 @@
1
+ import { services } from './mcp-tools.mjs';
2
+ import { isOwner, sameOrigin } from './office.mjs';
3
+ import { esc } from './site.mjs';
4
+ let load = async () => [];
5
+ export function useFunctions(loader) { load = loader; }
6
+ export async function hasFunctionType(type) { return (await load()).some(fn=>fn.event===type); }
7
+ export async function syncFunctions(env, definitions = null, now = Date.now()) {
8
+ definitions ??= await load();
9
+ if (!env.DB || !definitions.length) return definitions;
10
+ // Deploy owns the production registry; an old in-flight Worker must not overwrite it.
11
+ if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
12
+ const statements=definitions.map(fn=>env.DB.prepare(`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,(SELECT COALESCE(MAX(seq),0) FROM studio_events),?3) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type`).bind(fn.name,fn.event,now));
13
+ statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
14
+ await env.DB.batch(statements);
15
+ return definitions;
16
+ }
17
+ export async function emitEvent(env, type, data, {id = crypto.randomUUID(), at = Date.now()} = {}) {
18
+ await env.DB.prepare('INSERT OR IGNORE INTO studio_events(id,type,data,at) SELECT ?1,?2,?3,?4 WHERE EXISTS (SELECT 1 FROM function_subscriptions WHERE type=?2)').bind(id,type,JSON.stringify(data),at).run();
19
+ return id;
20
+ }
21
+ export async function replayFunction(env, name, definitions = null) {
22
+ definitions ??= await load();
23
+ if (!definitions.some(fn=>fn.name===name && fn.replay===true)) throw new Error('Declare replay: true before requesting retained history');
24
+ await env.DB.prepare('UPDATE function_subscriptions SET cursor=0 WHERE name=?1').bind(name).run();
25
+ }
26
+ export async function pruneEvents(env) {
27
+ // Visit a bounded sequence page even when millions of failed events remain.
28
+ const cursor=Number((await env.DB.prepare("SELECT value FROM meta WHERE key='function-prune-cursor'").first())?.value??0);
29
+ const rows=(await env.DB.prepare('SELECT seq,id FROM studio_events WHERE seq>?1 ORDER BY seq LIMIT 100').bind(cursor).all()).results;
30
+ const statements=rows.flatMap(({id})=>[
31
+ env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND (state='complete' OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
32
+ env.DB.prepare(`DELETE FROM studio_events WHERE id=?1 AND NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.type=studio_events.type AND s.cursor<studio_events.seq) AND NOT EXISTS (SELECT 1 FROM function_deliveries d WHERE d.event=studio_events.id)`).bind(id),
33
+ ]);
34
+ statements.push(env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-prune-cursor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(String(rows.at(-1)?.seq??0)));
35
+ await env.DB.batch(statements);
36
+ }
37
+ /** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
38
+ export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000} = {}) {
39
+ definitions = await syncFunctions(env,definitions,now());
40
+ if(!definitions.length)return;
41
+ if(typeof cat==='function')cat=await cat();
42
+ for (const fn of definitions) {
43
+ const subscription=await env.DB.prepare('SELECT cursor,type FROM function_subscriptions WHERE name=?1').bind(fn.name).first();
44
+ if(!subscription || subscription.type!==fn.event)continue;
45
+ const page=(await env.DB.prepare('SELECT seq,id,data FROM studio_events WHERE type=?1 AND seq>?2 ORDER BY seq LIMIT 100').bind(fn.event,subscription.cursor).all()).results;
46
+ if(page.length)await env.DB.batch([
47
+ ...page.filter(e=>!fn.schedule||JSON.parse(e.data).function===fn.name).map(e=>env.DB.prepare('INSERT OR IGNORE INTO function_deliveries(event,function) SELECT id,?2 FROM studio_events WHERE id=?1').bind(e.id,fn.name)),
48
+ env.DB.prepare('UPDATE function_subscriptions SET cursor=MAX(cursor,?2) WHERE name=?1').bind(fn.name,page.at(-1).seq),
49
+ ]);
50
+ const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.data,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state IN ('pending','failed','running') AND d.due<=?2 ORDER BY d.due LIMIT 100`).bind(fn.name,now()).all()).results;
51
+ for (const row of rows) {
52
+ const lease=crypto.randomUUID();
53
+ const claimed=await env.DB.prepare(`UPDATE function_deliveries SET state='running',lease=?3,due=?4,attempts=attempts+1 WHERE event=?1 AND function=?2 AND state!='complete' AND due<=?5`).bind(row.event,fn.name,lease,now()+leaseMs,now()).run();
54
+ if (!claimed.meta.changes) continue;
55
+ try {
56
+ const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
57
+ await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
58
+ await env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()).run();
59
+ } catch {
60
+ // Handler errors may contain credentials. The office identifies the function/event; Worker logs are the studio's debugging surface.
61
+ await env.DB.prepare(`UPDATE function_deliveries SET state='failed',error='Function failed; inspect its code and retry',due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,now()+leaseMs).run();
62
+ }
63
+ }
64
+ }
65
+ await pruneEvents(env);
66
+ }
67
+ export async function scheduleFunctions(event,env,cat,origin) {
68
+ const definitions=await syncFunctions(env);
69
+ for(const fn of definitions) if(fn.schedule===event.cron) await env.DB.batch([
70
+ env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at) SELECT ?1,?2,?3,?4 WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE name=?5 AND last_tick<?4)`).bind(`schedule:${fn.name}:${event.scheduledTime}`,fn.event,JSON.stringify({function:fn.name,cron:event.cron,scheduledTime:event.scheduledTime}),event.scheduledTime,fn.name),
71
+ env.DB.prepare('UPDATE function_subscriptions SET last_tick=MAX(last_tick,?2) WHERE name=?1').bind(fn.name,event.scheduledTime),
72
+ ]);
73
+ await runFunctions(env,cat,origin,{definitions});
74
+ }
75
+ export async function functionsRoute(request,env,cat) {
76
+ const url=new URL(request.url);
77
+ if (!await isOwner(request,env)) return new Response('Sign in to the office',{status:403});
78
+ if(request.method==='POST') {
79
+ if(!sameOrigin(request,url))return new Response('Origin refused',{status:403});
80
+ const form=request.headers.get('content-type')?.includes('application/x-www-form-urlencoded');
81
+ const body=form?Object.fromEntries(await request.formData()):await request.json();
82
+ if(body.replay) await replayFunction(env,body.replay);
83
+ if(body.retry) await env.DB.prepare(`UPDATE function_deliveries SET due=0 WHERE event=?1 AND state='failed'`).bind(body.retry).run();
84
+ if(body.fire) {
85
+ if(!['localhost','127.0.0.1','[::1]'].includes(url.hostname))return new Response('Local testing only',{status:403});
86
+ await emitEvent(env,body.fire,body.data??{},{id:body.id??crypto.randomUUID()});
87
+ }
88
+ await runFunctions(env,cat,url.origin);
89
+ return form?Response.redirect(url.href,303):Response.json({ok:true});
90
+ }
91
+ const rows=(await env.DB.prepare(`SELECT d.*,e.type,e.at FROM function_deliveries d JOIN studio_events e ON e.id=d.event ORDER BY e.at DESC LIMIT 100`).all()).results;
92
+ return new Response(`<!doctype html><meta charset="utf-8"><title>Studio functions</title><h1>Studio functions</h1><p>At-least-once delivery. Use the event ID to deduplicate effects.</p><table><tr><th>Function</th><th>Event ID</th><th>Event</th><th>State</th><th>Attempts</th><th>Error</th><th>Retry</th></tr>${rows.map(r=>`<tr>${[r.function,r.event,r.type,r.state,r.attempts,r.error??''].map(v=>`<td>${esc(v)}</td>`).join('')}<td>${r.state==='failed'?`<form method="post"><input type="hidden" name="retry" value="${esc(r.event)}"><button>Retry</button></form>`:''}</td></tr>`).join('')}</table>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store'}});
93
+ }
package/worker/index.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ export { useFunctions } from './functions.mjs';
1
2
  import { appAccess, appRecordsRoute } from './app-records.mjs';
2
3
  import { appRole, openPath } from './app-format.mjs';
3
4
  /**
@@ -600,13 +601,17 @@ function finish(res, path) {
600
601
 
601
602
  export default {
602
603
  async scheduled(event, env, ctx) {
603
- ctx.waitUntil((async () => {
604
+ const maintenance=(async () => {
604
605
  const cat = await catalogue(env, 'https://studio.invalid');
605
606
  await reconcileOrders(env, shopOf(cat));
606
- })());
607
+ await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
608
+ })();
609
+ ctx.waitUntil(maintenance);
610
+ await maintenance;
607
611
  },
608
612
  async fetch(request, env, ctx) {
609
613
  const url = new URL(request.url);
614
+ await (await import('./functions.mjs')).syncFunctions(env);
610
615
  // A standalone copy's Lobby call (worker/standalone.mjs): every answer to it can be read by the app's page, an
611
616
  // unknown game's 404 and a failure included, so the app can say why it plays offline. No other address is opened.
612
617
  const appRecords = isAppOrigin(request) && /^\/[^/]+\/api\/app\/records\//.test(url.pathname);
@@ -618,6 +623,7 @@ export default {
618
623
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
619
624
  res = json({ ok: false, error: 'failed' }, 500);
620
625
  }
626
+ if(ctx?.waitUntil && ['POST','PUT','DELETE'].includes(request.method)) ctx.waitUntil((async()=>{const {runFunctions}=await import('./functions.mjs');await runFunctions(env,()=>catalogue(env,url.origin),url.origin);})());
621
627
  if (appLobby || appRecords) return appCors(request, res);
622
628
  // A referral's arrival (worker/referrals.mjs): only a person's page load with ?via= of another site, on a studio
623
629
  // that pays referrals. The page is answered first; a cookie is added to it only then.
@@ -635,6 +641,7 @@ export default {
635
641
  let studioTools = async () => [];
636
642
  /** Studio-authored Worker code, loaded only on the MCP surface. */
637
643
  export function useTools(load) { studioTools = load; }
644
+ export function getStudioTools() { return studioTools(); }
638
645
 
639
646
  async function route(request, env, ctx) {
640
647
  const url = new URL(request.url);
@@ -647,6 +654,8 @@ async function route(request, env, ctx) {
647
654
  const getAll = () => (catP ??= catalogue(env, url.origin).then((cat) => named(cat, env)));
648
655
  const getCat = () => (pubP ??= Promise.all([getAll(), settingsOf(env)]).then(([cat, settings]) => publicCatalogue(cat, settings, env)));
649
656
 
657
+ if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
658
+
650
659
  if (path === '/mcp' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
651
660
  const { remoteMcp } = await import('./mcp.mjs');
652
661
  return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
@@ -1672,11 +1681,35 @@ export class Table {
1672
1681
  * a failure goes to the error stream, everything else to the log (and not at all with HOMIE_ROOM_LOG=0).
1673
1682
  */
1674
1683
  say(line) {
1684
+ if(line?.ev==='hello'&&!line.resumed&&line.seat!==null) this.queueFunctionEvent('player.joined',{game:this.game,room:line.room,player:this.room?.clients.get(line.id)?.conn?.player??null,seat:line.seat});
1675
1685
  const bad = line?.ev === 'failed' || line?.ev === 'socket-error' || line?.ev === 'tick-failed' || line?.ev === 'persist-failed';
1676
1686
  if (!bad && this.env.HOMIE_ROOM_LOG === '0') return;
1677
1687
  try { console[bad ? 'error' : 'log'](JSON.stringify({ at: new Date().toISOString(), ...line, game: this.game ?? null, room: line?.room ?? this.code ?? null })); } catch { /* no console */ }
1678
1688
  }
1679
1689
 
1690
+ queueFunctionEvent(type,data) {
1691
+ if(!this.env.DB || this.env.HOMIE_PREVIEW==='1' || !this.ctx.storage.sql) return;
1692
+ this.ctx.waitUntil((async()=>{
1693
+ if(!await (await import('./functions.mjs')).hasFunctionType(type))return;
1694
+ const sql=this.ctx.storage.sql;
1695
+ sql.exec('CREATE TABLE IF NOT EXISTS function_outbox(id TEXT PRIMARY KEY,type TEXT,data TEXT,at INTEGER)');
1696
+ sql.exec('INSERT INTO function_outbox VALUES(?,?,?,?)',crypto.randomUUID(),type,JSON.stringify(data),Date.now());
1697
+ this.armRoom(Date.now()+60000);
1698
+ await this.flushFunctionEvents();
1699
+ })());
1700
+ }
1701
+
1702
+ async flushFunctionEvents() {
1703
+ const sql=this.ctx.storage.sql;
1704
+ if(!sql || !this.env.DB)return;
1705
+ if(!sql.exec("SELECT name FROM sqlite_master WHERE name='function_outbox'").toArray().length)return;
1706
+ const {emitEvent}=await import('./functions.mjs');
1707
+ for(const row of sql.exec('SELECT * FROM function_outbox LIMIT 100').toArray()) {
1708
+ try {await emitEvent(this.env,row.type,JSON.parse(row.data),{id:row.id,at:row.at});sql.exec('DELETE FROM function_outbox WHERE id=?',row.id);}
1709
+ catch {this.armRoom(Date.now()+60000);await this.alarmWrite;return;}
1710
+ }
1711
+ }
1712
+
1680
1713
  /**
1681
1714
  * A socket's `error` event. The runtime raises one when the other end simply went away ("Network connection lost":
1682
1715
  * a closed tab, a phone that slept, a browser the test harness shut): that is a departure, and the room handles it
@@ -1893,6 +1926,7 @@ export class Table {
1893
1926
 
1894
1927
  /** The alarm: the house guides' brains decide (worker/agents.mjs), then the day's usage is written. */
1895
1928
  async alarm() {
1929
+ await this.flushFunctionEvents();
1896
1930
  // A brain that throws never takes the room with it: the alarm is not retried, the guides answer from the floor.
1897
1931
  try { if (this.house) await this.house.onAlarm(); } catch (error) { try { console.log(JSON.stringify({ ev: 'brain-alarm-failed', room: this.code, error: String(error?.stack ?? error).slice(0, 400) })); } catch { /* no console */ } }
1898
1932
  try { await this.flushBrain(); } catch { /* counted next time */ }
@@ -2099,6 +2133,7 @@ export class Table {
2099
2133
 
2100
2134
  /** Tell the Lobby this room closed (until when; 0 when it opened again), so it sends nobody here meanwhile. */
2101
2135
  tellLobby(extra) {
2136
+ if(extra.closed || extra.ended) this.queueFunctionEvent('room.closed',{game:this.game,room:this.code,...extra});
2102
2137
  if (!this.game || !this.code) return;
2103
2138
  const lobby = this.env.LOBBY.get(this.env.LOBBY.idFromName(this.game));
2104
2139
  this.lastReport = -1;
@@ -39,7 +39,7 @@ export async function paymentProtocol(
39
39
  });
40
40
  capabilities ??= await machineCapabilities(env);
41
41
  let methods = capabilities.machine.card ? [payments.spt.charge()] : [];
42
- if (config.base && capabilities.machine.base && !mcp) {
42
+ if (order.resource_kind !== 'service' && config.base && capabilities.machine.base && !mcp) {
43
43
  facilitator ??= new HTTPFacilitatorClient(
44
44
  mode === "live"
45
45
  ? createFacilitatorConfig(env.CDP_API_KEY_ID, env.CDP_API_KEY_SECRET)
@@ -24,7 +24,8 @@ export function oauthStorage(DB) {
24
24
  },
25
25
  };
26
26
  }
27
- export async function rateLimit(env, caller, limit = 60) {
27
+ export async function rateLimit(env, caller, limit = null) {
28
+ if (limit == null) return true;
28
29
  const minute = Math.floor(Date.now()/60000);
29
30
  const row = await env.DB.prepare('INSERT INTO mcp_limits(caller,minute,n) VALUES(?1,?2,1) ON CONFLICT(caller,minute) DO UPDATE SET n=n+1 RETURNING n').bind(caller,minute).first();
30
31
  return row.n <= limit;
@@ -41,5 +42,5 @@ export async function audit(env, caller, tool, outcome) {
41
42
 
42
43
  export function callsPerMinute(cat) {
43
44
  const value=cat.studio?.mcp?.callsPerMinute;
44
- return Number.isSafeInteger(value)&&value>0?value:60;
45
+ return Number.isSafeInteger(value)&&value>0?value:null;
45
46
  }
@@ -1,3 +1,4 @@
1
+ import { selling } from './extensions.mjs';
1
2
  import { withToolIdentity } from './tool-identity.mjs';
2
3
  import { officeRoutes } from './office.mjs';
3
4
  import { appRecordsRoute } from './app-records.mjs';
@@ -43,10 +44,11 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
43
44
  const result = await response.json(); if(!response.ok) throw new Error(result.error);return result;
44
45
  };
45
46
  const office = async (path, body, query={}) => {
46
- if (!caller.owner || !/^\/_studio\/api\//.test(path) || path.includes('..') || path.includes('?')) throw new Error('owner only');
47
+ const delegated=definition.delegateOffice && await allowed(definition.audience,caller,env);
48
+ if ((!caller.owner && !delegated) || !/^\/_studio\/api\//.test(path) || path.includes('..') || path.includes('?')) throw new Error('owner only');
47
49
  const url = new URL(path,origin);
48
50
  for(const [key,value] of Object.entries(query))url.searchParams.set(key,String(value));
49
- const request = withToolIdentity(new Request(url,{method:body?'POST':'GET',headers:{origin,'content-type':'application/json'},...(body?{body:JSON.stringify(body)}:{})}),caller);
51
+ const request = withToolIdentity(new Request(url,{method:body?'POST':'GET',headers:{origin,'content-type':'application/json'},...(body?{body:JSON.stringify(body)}:{})}),delegated?{...caller,owner:true}:caller);
50
52
  const response = await officeRoutes(request,env,url,{catalogueOf:async()=>cat});
51
53
  const result = response.headers.get('content-type')?.includes('application/json')?await response.json():{text:await response.text()}; if(!response.ok && response.status!==202) throw new Error(result.error);return result;
52
54
  };
@@ -57,7 +59,7 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
57
59
  shop:()=>office('/_studio/api/shop'),
58
60
  database:Object.freeze({
59
61
  async get(k){const r=await env.DB.prepare('SELECT value FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first();return r?JSON.parse(r.value):null;},
60
- async put(k,value){const text=JSON.stringify(value);if(text.length>16384)throw new Error('value too large');await env.DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value').bind(namespace,key(k),text).run();},
62
+ async put(k,value){const text=JSON.stringify(value);await env.DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value').bind(namespace,key(k),text).run();},
61
63
  }),
62
64
  fetch:globalThis.fetch.bind(globalThis),
63
65
  // A named secret may authenticate an HTTPS request; its value is never returned.
@@ -77,6 +79,15 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
77
79
  export function builtins(env,cat,origin) {
78
80
  const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
79
81
  const tools=[
82
+ tool('studio_function_replay','Replay retained events for a declaration with replay: true.','owner',object({name:str},['name']),async({name})=>{
83
+ const {replayFunction,runFunctions}=await import('./functions.mjs');
84
+ await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
85
+ }),
86
+ 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)=>{
87
+ if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
88
+ const {emitEvent,runFunctions}=await import('./functions.mjs');
89
+ const id=await emitEvent(env,args.event,args.data??{},{id:args.id});await runFunctions(env,cat,origin);return {id};
90
+ }),
80
91
  tool('studio_info','The public studio, apps, games and posts.','public',object(),async()=>{const pub=publicCatalogue(cat,await settingsOf(env),env);return {studio:pub.studio?.name,games:pub.games.map(g=>({id:g.id,name:g.name,kind:g.kind??'game'})),posts:pub.posts};}),
81
92
  tool('studio_office','Live rooms and the players as shown in the office.','owner',object(),(_,ctx)=>ctx.office('/_studio/api/office')),
82
93
  tool('studio_shop','Orders, refunds and the shop state.','owner',object(),(_,ctx)=>ctx.shop()),
@@ -101,5 +112,6 @@ export function builtins(env,cat,origin) {
101
112
  const actions=['announce','kick','mute','close','game','servers','servers/set','servers/close','servers/member','room-level','agents/brain','chat/rules','chat/remove','chat/report','chat/budget','lounge/rules','lounge/night','lounge/mod','lounge/remove','lounge/hold','shop/release','shop/refund','shop/settle','invites','invites/revoke'];
102
113
  tools.push(tool('studio_office_action','Use an existing office control. Destructive changes return a browser confirmation for the owner.','owner',object({operation:{enum:actions},action:{type:'object'}},['operation','action']),({operation,action},ctx)=>ctx.office('/_studio/api/'+operation,action)));
103
114
  for (const op of ['sit','look','act','speak','stand']) tools.push(tool('agent_'+op, 'A marked AI guide: '+op+'. Room policy and declared vocabulary apply.', 'signed-in', object({game:str,role:str,server:str,room:str,goal:str,line:str,args:{type:'object'}},['game']), async (args,ctx)=>(await import('./mcp-room.mjs')).remoteSeat(env,cat,ctx.caller,op,args)));
104
- return tools;
115
+ tools.push(...selling.customerTools(env,cat,origin));
116
+ return tools.map(tool => ({...tool, delegateOffice:Object.hasOwn(cat.studio?.mcp?.audiences??{},tool.name), audience: cat.studio?.mcp?.audiences?.[tool.name] ?? tool.audience}));
105
117
  }
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
+ }