@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.
- package/CHANGELOG.md +55 -1
- package/README.md +11 -0
- package/bin/homie-studio.mjs +8 -0
- package/functions/FUNCTIONS.md +99 -0
- package/functions/functions.ts +13 -0
- package/lib/build.mjs +2 -0
- package/lib/cloudflare.mjs +9 -9
- package/lib/dev.mjs +2 -1
- package/lib/functions-build.mjs +36 -0
- package/lib/functions-cli.mjs +23 -0
- package/lib/port-check.mjs +6 -3
- package/lib/purchase-links.mjs +8 -0
- package/lib/rules-build.mjs +8 -1
- package/lib/rules-check-worker.mjs +3 -3
- package/lib/rules-check.mjs +3 -1
- package/lib/scaffold.mjs +16 -3
- package/lib/shop.mjs +1 -1
- package/lib/template-history.json +85 -0
- package/lib/tools-build.mjs +2 -0
- package/lib/upgrade.mjs +2 -0
- package/lib/worker-build.mjs +8 -20
- package/lib/worker-config.mjs +28 -0
- package/netplay/NETPLAY.md +74 -2
- package/package.json +7 -2
- package/rules/collision.ts +115 -0
- package/rules/core.ts +65 -25
- package/rules/host.ts +5 -1
- package/rules/math.ts +15 -5
- package/rules/rules.ts +33 -10
- package/rules/types.ts +2 -2
- package/rules/view.ts +126 -32
- package/shop/SHOP.md +4 -3
- package/starters/ember-vale/game.json +16 -4
- package/starters/ember-vale/map/main.json +66 -0
- package/starters/ember-vale/src/move.ts +31 -0
- package/starters/ember-vale/src/rules.ts +244 -0
- package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
- package/starters/ember-vale/tunables.json +100 -11
- package/starters/gem-rush/src/move.ts +9 -1
- package/starters/gem-rush/src/view.ts +2 -1
- package/starters/gem-rush-3d/game.json +23 -5
- package/starters/gem-rush-3d/map/main.json +153 -0
- package/starters/gem-rush-3d/src/move.ts +37 -0
- package/starters/gem-rush-3d/src/rules.ts +133 -0
- package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
- package/starters/gem-rush-3d/tunables.json +98 -11
- package/starters/hero-rush-3d/game.json +23 -5
- package/starters/hero-rush-3d/map/main.json +153 -0
- package/starters/hero-rush-3d/src/move.ts +49 -0
- package/starters/hero-rush-3d/src/rules.ts +200 -0
- package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
- package/starters/hero-rush-3d/tunables.json +260 -30
- package/tools/TOOLS.md +42 -2
- package/tools/tools.ts +3 -0
- package/worker/customer-mcp.mjs +77 -0
- package/worker/customer-resources.mjs +53 -0
- package/worker/customer-shop.mjs +54 -0
- package/worker/extensions.mjs +1 -0
- package/worker/functions-schema.mjs +55 -0
- package/worker/functions.mjs +93 -0
- package/worker/index.mjs +37 -2
- package/worker/machine-payments.mjs +1 -1
- package/worker/mcp-store.mjs +3 -2
- package/worker/mcp-tools.mjs +16 -4
- package/worker/mcp.mjs +31 -15
- package/worker/office-page.mjs +1 -1
- package/worker/players.mjs +5 -2
- package/worker/purchase-delivery.mjs +1 -0
- package/worker/purchase-hosted-readiness.mjs +0 -1
- package/worker/purchase-links.mjs +1 -1
- package/worker/purchase-machine.mjs +1 -0
- package/worker/purchase-office.mjs +1 -1
- package/worker/purchase-owner.mjs +0 -2
- package/worker/purchase-reconciliation.mjs +1 -0
- package/worker/purchase-routes.mjs +11 -17
- package/worker/purchase-store.mjs +1 -0
- package/worker/selling/runtime.mjs +4 -1
- package/worker/service-run.mjs +25 -0
- package/worker/service-terms.mjs +2 -0
- package/worker/shop-rules.mjs +9 -8
- package/worker/shop.mjs +22 -9
- package/worker/tool-webhook.mjs +1 -0
- 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
|
-
|
|
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)
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -24,7 +24,8 @@ export function oauthStorage(DB) {
|
|
|
24
24
|
},
|
|
25
25
|
};
|
|
26
26
|
}
|
|
27
|
-
export async function rateLimit(env, caller, limit =
|
|
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:
|
|
45
|
+
return Number.isSafeInteger(value)&&value>0?value:null;
|
|
45
46
|
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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
|
-
|
|
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);
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
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
|
|
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
|
-
|
|
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
|
|
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);
|
package/worker/office-page.mjs
CHANGED
|
@@ -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>
|
package/worker/players.mjs
CHANGED
|
@@ -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
|
+
}
|