@homie-rocks/studio 0.54.0 → 0.56.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 +66 -0
- package/README.md +2 -0
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +65 -23
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/port-check.mjs +3 -2
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +30 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +87 -0
- package/lib/worker-build.mjs +16 -4
- package/lib/worker-config.mjs +10 -1
- package/netplay/NETPLAY.md +5 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +6 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +31 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +106 -91
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +56 -2785
- package/worker/lazy-objects.mjs +43 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +319 -0
- package/worker/lounge-store.mjs +2 -2
- package/worker/lounge.mjs +3 -3
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp-tools.mjs +4 -2
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +18 -21
- package/worker/operational-schema.mjs +16 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +9 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +36 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +217 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +54 -740
- package/worker/rooms-cache.mjs +19 -0
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +670 -0
- package/worker/runtime-support.mjs +447 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/session-codec.mjs +24 -0
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +9 -7
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1448 -0
- package/worker/tool-registry.mjs +10 -0
- package/worker/version.mjs +1 -1
- package/worker/watch-sockets.mjs +75 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {lobbyShard,LOBBY_SHARDS} from './lobby-shards.mjs';
|
|
2
|
+
|
|
3
|
+
// Entry objects partition reservation ownership. They lease 8 routing decisions
|
|
4
|
+
// at a time, so the policy object sees 1/8 of join traffic, never room reports.
|
|
5
|
+
// A quiet game refills existing pools before opening shard zero. Spread above
|
|
6
|
+
// 128 observed joins/s: below half the slowest single-coordinator throughput
|
|
7
|
+
// measured by the deployed fixture (1024 durable joins, 32 concurrent).
|
|
8
|
+
// Count consumed leases, not all eight slots in a newly issued lease.
|
|
9
|
+
export class MatchRouter {
|
|
10
|
+
constructor(ctx,env) { this.ctx=ctx;this.env=env;this.pending=new Map();this.routes=new Map(); }
|
|
11
|
+
async fetch(request) {
|
|
12
|
+
const url=new URL(request.url),now=Date.now();
|
|
13
|
+
if(url.pathname==='/match-policy') {
|
|
14
|
+
this.demand=(this.demand??[]).filter(row=>now-row.at<1000);
|
|
15
|
+
this.demand.push({at:now,joins:Math.max(1,Math.min(8,Number(url.searchParams.get('used'))||1))});
|
|
16
|
+
return Response.json({wide:this.demand.reduce((n,row)=>n+row.joins,0)>128,until:now+1000});
|
|
17
|
+
}
|
|
18
|
+
const identity=url.searchParams.get('reservation');
|
|
19
|
+
const key=JSON.stringify([identity,url.searchParams.get('ver'),url.searchParams.get('host'),url.searchParams.get('not')]);
|
|
20
|
+
if(this.pending.has(key))return Response.json(await this.pending.get(key));
|
|
21
|
+
const task=this.join(url,key,now);this.pending.set(key,task);
|
|
22
|
+
try{return Response.json(await task);}finally{this.pending.delete(key);}
|
|
23
|
+
}
|
|
24
|
+
async join(url,key,now) {
|
|
25
|
+
const game=url.searchParams.get('game');
|
|
26
|
+
for(const [id,row] of this.routes)if(row.until<=now)this.routes.delete(id);
|
|
27
|
+
let route=this.routes.get(key)??await this.ctx.storage.get('match:'+key);
|
|
28
|
+
if(!route||route.until<=now) {
|
|
29
|
+
// Serialize lease consumption, not requests to the matching shards.
|
|
30
|
+
const previous=this.leasing??Promise.resolve();
|
|
31
|
+
const lease=previous.then(async()=>{
|
|
32
|
+
if(!this.policy||this.policy.until<=now||!this.remaining){
|
|
33
|
+
const target=new URL('https://lobby/match-policy');target.searchParams.set('game',game);target.searchParams.set('used',String(this.policy?8-this.remaining:1));
|
|
34
|
+
const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(game+'/match-policy')).fetch(target);
|
|
35
|
+
if(!response.ok)throw Error('Matching policy unavailable');
|
|
36
|
+
this.policy=await response.json();this.remaining=8;
|
|
37
|
+
}
|
|
38
|
+
this.remaining--;
|
|
39
|
+
return this.policy.wide?lobbyShard(url.searchParams.get('reservation')):null;
|
|
40
|
+
});
|
|
41
|
+
this.leasing=lease.then(()=>{},()=>{});
|
|
42
|
+
let shard=await lease;
|
|
43
|
+
if(shard===null) {
|
|
44
|
+
// Read-only discovery: persist ownership before reserving a seat. Quiet
|
|
45
|
+
// joins may refill any pool left open by a burst, including after eviction.
|
|
46
|
+
shard=0;
|
|
47
|
+
for(let candidate=0;candidate<LOBBY_SHARDS;candidate++){
|
|
48
|
+
const target=new URL(url);target.pathname='/join';target.searchParams.set('peek','1');target.searchParams.set('shard',String(candidate));
|
|
49
|
+
const name=candidate?`${game}/shard/${candidate}`:game;
|
|
50
|
+
const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(target);
|
|
51
|
+
if(!response.ok)throw Error('Matching discovery unavailable');
|
|
52
|
+
if((await response.json()).room){shard=candidate;break;}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
route={shard,until:now+20000};
|
|
56
|
+
// Persist ownership before forwarding: a lost response or entry eviction
|
|
57
|
+
// must retry the same shard, even while the game changes routing policy.
|
|
58
|
+
await this.ctx.storage.put('match:'+key,route);this.routes.set(key,route);
|
|
59
|
+
await this.ctx.storage.setAlarm?.(now+20000);
|
|
60
|
+
}
|
|
61
|
+
url.pathname='/join';url.searchParams.set('shard',String(route.shard));
|
|
62
|
+
const name=route.shard?`${game}/shard/${route.shard}`:game;
|
|
63
|
+
const response=await this.env.LOBBY.get(this.env.LOBBY.idFromName(name)).fetch(url);
|
|
64
|
+
if(!response.ok)throw Error('Matching shard unavailable');
|
|
65
|
+
return response.json();
|
|
66
|
+
}
|
|
67
|
+
async alarm() {
|
|
68
|
+
const now=Date.now();let next=Infinity;
|
|
69
|
+
for(const [key,row] of await this.ctx.storage.list({prefix:'match:'})) {
|
|
70
|
+
if(row.until<=now)await this.ctx.storage.delete(key);else next=Math.min(next,row.until);
|
|
71
|
+
}
|
|
72
|
+
if(Number.isFinite(next))await this.ctx.storage.setAlarm(next);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -4,7 +4,7 @@ import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseF
|
|
|
4
4
|
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
5
|
export class McpEventStreams {
|
|
6
6
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
-
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(error=>console.warn("MCP stream cleanup deferred to expiry:",error.message)));}
|
|
8
8
|
async write(stream,value,authorize){
|
|
9
9
|
if(stream.closed)return false;
|
|
10
10
|
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
@@ -19,7 +19,7 @@ export class McpEventStreams {
|
|
|
19
19
|
const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
|
|
20
20
|
const start=await eventStart(this.env,input.params);
|
|
21
21
|
const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
|
|
22
|
-
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+
|
|
22
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Math.floor(Date.now()/1000)+60).run();
|
|
23
23
|
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
24
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
25
|
this.streams.add(stream);
|
|
@@ -46,7 +46,7 @@ export class McpEventStreams {
|
|
|
46
46
|
}
|
|
47
47
|
stream.cursor=page.cursor;more=page.hasMore;
|
|
48
48
|
}
|
|
49
|
-
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+
|
|
49
|
+
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Math.floor(Date.now()/1000)+60).run();
|
|
50
50
|
await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
|
|
51
51
|
}catch(error){
|
|
52
52
|
const details=eventError(error);
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
|
|
|
78
78
|
const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
|
|
79
79
|
if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
80
80
|
if(previous&&!live)await removeEventHook(env,id);
|
|
81
|
-
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key
|
|
81
|
+
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;')").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
|
|
82
82
|
const start=await eventStart(env,params,now());
|
|
83
83
|
const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
|
|
84
84
|
if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
|
|
@@ -98,15 +98,15 @@ export async function unsubscribeEvents(env,caller,params){
|
|
|
98
98
|
/** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
|
|
99
99
|
export async function eventHookSubscriptions(env,cat,options={}){
|
|
100
100
|
const now=options.now??Date.now,definitions=[];let after='';
|
|
101
|
-
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key
|
|
101
|
+
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key >= 'mcp-event-hook:' AND key < 'mcp-event-hook;') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
102
102
|
for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
|
|
103
103
|
let feed;
|
|
104
104
|
try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
105
|
-
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
|
|
105
|
+
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,sub.id);continue;}
|
|
106
106
|
definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
|
|
107
107
|
const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
|
|
108
108
|
try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
|
|
109
|
-
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
|
|
109
|
+
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{/* Termination is best effort; the failed subscription is already being retired. */}await removeEventHook(env,fresh.id);return;}
|
|
110
110
|
try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
|
|
111
111
|
catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
|
|
112
112
|
}});
|
package/worker/mcp-room.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {lobbyFor} from './lobby-shards.mjs';
|
|
1
2
|
import {signControl,settingsOf,launchOf,seatsFor} from './office.mjs';
|
|
2
3
|
import {appAccess} from './app-records.mjs';
|
|
3
4
|
import {withToolIdentity} from './tool-identity.mjs';
|
|
@@ -23,7 +24,7 @@ export async function remoteSeat(env,cat,caller,op,args) {
|
|
|
23
24
|
if(refusal)throw new Error(refusal.message);
|
|
24
25
|
let room=args.room;
|
|
25
26
|
if(!room&&op==='sit'){
|
|
26
|
-
const joined=await
|
|
27
|
+
const joined=await lobbyFor(env,meta.id).fetch(`https://lobby/join?max=${max}&server=${serverId}&agent=1&ai=${policy.aiSeats+policy.guides}`,{method:'POST'});room=(await joined.json()).room;
|
|
27
28
|
}
|
|
28
29
|
if(!/^[A-Za-z0-9_-]{1,32}$/.test(room??''))throw new Error('No room with people; an AI never starts a room');
|
|
29
30
|
const ctl=await signControl(env,{op:'mcp-seat',game:meta.id,room,args:{...args,operation:op,connection:caller.connection,person:caller.id,name:caller.name??'Studio AI'}});
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
/** mcp_kv.expires is Unix seconds for ALL rows (OAuth, webhook dedupe and streams); NULL never expires. */
|
|
1
2
|
/** OAuth's KV contract on the studio's existing D1. No new Cloudflare resource. */
|
|
2
3
|
export const MCP_MIGRATION_FILE = '0017_studio_mcp.sql';
|
|
3
4
|
export const MCP_MIGRATION = `CREATE TABLE IF NOT EXISTS mcp_kv (key TEXT PRIMARY KEY, value TEXT NOT NULL, expires INTEGER);
|
|
@@ -6,6 +7,15 @@ CREATE TABLE IF NOT EXISTS mcp_audit (id INTEGER PRIMARY KEY AUTOINCREMENT, pers
|
|
|
6
7
|
CREATE TABLE IF NOT EXISTS mcp_limits (caller TEXT NOT NULL, minute INTEGER NOT NULL, n INTEGER NOT NULL, PRIMARY KEY(caller, minute));
|
|
7
8
|
CREATE TABLE IF NOT EXISTS tool_data (namespace TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, PRIMARY KEY(namespace,key));
|
|
8
9
|
`;
|
|
10
|
+
/** Exclusive bound in SQLite's binary text order; null means no upper bound. */
|
|
11
|
+
export function prefixEnd(prefix) {
|
|
12
|
+
const points=Array.from(prefix, c=>c.codePointAt(0));
|
|
13
|
+
while(points.length) {
|
|
14
|
+
const last=points.pop();
|
|
15
|
+
if(last<0x10ffff)return String.fromCodePoint(...points,last===0xd7ff?0xe000:last+1);
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
9
19
|
export function oauthStorage(DB) {
|
|
10
20
|
return {
|
|
11
21
|
async get(key, options) {
|
|
@@ -19,7 +29,8 @@ export function oauthStorage(DB) {
|
|
|
19
29
|
async delete(key) { await DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(key).run(); },
|
|
20
30
|
async list({prefix = '', cursor = '', limit = 1000} = {}) {
|
|
21
31
|
const n = Math.max(1,Math.min(1000,limit));
|
|
22
|
-
const
|
|
32
|
+
const end=prefixEnd(prefix);
|
|
33
|
+
const rows = (await DB.prepare(`SELECT key,expires FROM mcp_kv WHERE key>=?1 ${end===null?'':'AND key<?2'} AND key>?3 AND (expires IS NULL OR expires>?4) ORDER BY key LIMIT ?5`).bind(prefix,end,cursor,Math.floor(Date.now()/1000),n+1).all()).results;
|
|
23
34
|
return { keys: rows.slice(0,n).map(r=>({name:r.key,...(r.expires ? {expiration:r.expires}: {})})), list_complete: rows.length<=n, cursor: rows.length>n ? rows[n-1].key : '' };
|
|
24
35
|
},
|
|
25
36
|
};
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -78,8 +78,10 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
78
78
|
fetch:globalThis.fetch.bind(globalThis),
|
|
79
79
|
// A named secret may authenticate an HTTPS request; its value is never returned.
|
|
80
80
|
async fetchWithSecret(name,url,init={}) {
|
|
81
|
-
const rule=cat.studio?.mcp?.secrets?.[name];
|
|
82
|
-
|
|
81
|
+
const rule=cat.studio?.mcp?.secrets?.[name];
|
|
82
|
+
const permittedOrigin=rule?.origin==='self'?new URL(origin).origin:rule?.origin;
|
|
83
|
+
const target=new URL(url,rule?.origin==='self'?permittedOrigin:undefined);
|
|
84
|
+
if(!rule || target.protocol!=='https:' || target.username || target.password || target.origin!==permittedOrigin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
|
|
83
85
|
const secret=env[name];if(typeof secret!=='string')throw new Error('secret unavailable');
|
|
84
86
|
const headers=new Headers(init.headers);headers.set(rule.header??'authorization',`${rule.prefix??'Bearer '}${secret}`);
|
|
85
87
|
const res=await fetch(target,{...init,headers,redirect:'error'});
|
package/worker/mcp.mjs
CHANGED
|
@@ -52,7 +52,11 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
52
52
|
}
|
|
53
53
|
const cat=await catalogueOf();
|
|
54
54
|
const tools=[...builtins(env,cat,origin),...definitions];
|
|
55
|
-
|
|
55
|
+
// Catalogue notifications update existing streams; the caller's response
|
|
56
|
+
// uses the catalogue above and does not depend on that delivery finishing.
|
|
57
|
+
const refresh=pushToolList(env,cat,tools,origin);
|
|
58
|
+
if(ctx?.waitUntil)ctx.waitUntil(refresh);
|
|
59
|
+
else await refresh;
|
|
56
60
|
if(request.method==='GET'&&env.LOBBY) {
|
|
57
61
|
if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
|
|
58
62
|
const connection=caller.connection??'public:'+crypto.randomUUID();
|
package/worker/office.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {lobbyFor} from './lobby-shards.mjs';
|
|
1
2
|
import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
|
|
2
3
|
import { selling } from './extensions.mjs';
|
|
3
4
|
import { purchaseOrderIfPresent } from './shop.mjs';
|
|
@@ -63,13 +64,13 @@ import { toolIdentity } from './tool-identity.mjs';
|
|
|
63
64
|
* from an office key it is ALWAYS an ask (the AI may only propose one).
|
|
64
65
|
*/
|
|
65
66
|
import { seatsOf } from './seats.mjs';
|
|
66
|
-
import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
|
|
67
|
+
import { diagnosticsOf, gatePlayers, roomLayout } from './gate-routing.mjs';
|
|
67
68
|
import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
|
|
68
69
|
import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
|
|
69
70
|
import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
|
|
70
71
|
import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
|
|
71
72
|
import { esc, layout, notFoundPage } from './site.mjs';
|
|
72
|
-
|
|
73
|
+
|
|
73
74
|
import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
|
|
74
75
|
import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
|
|
75
76
|
import { checkRelease, performRelease, checkRefund, describeRefund, officeShopPage, ordersCsv, performRefund, shopOffice } from './shop.mjs';
|
|
@@ -142,12 +143,8 @@ export function canonical(v) {
|
|
|
142
143
|
|
|
143
144
|
/* ------------------------------------------------------------------ player accounts (a seam) */
|
|
144
145
|
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
* The player-accounts API, handed over once by a studio's Worker that has it:
|
|
148
|
-
* `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
|
|
149
|
-
*/
|
|
150
|
-
export function usePlayers(api) { players = api && typeof api === 'object' ? api : null; }
|
|
146
|
+
import {players} from './player-registry.mjs';
|
|
147
|
+
export {usePlayers} from './player-registry.mjs';
|
|
151
148
|
|
|
152
149
|
async function playerOf(env, id) {
|
|
153
150
|
if (!players?.get || !id) return null;
|
|
@@ -414,7 +411,7 @@ export async function redeemInvite(request, env, url, cat, meta, settings) {
|
|
|
414
411
|
|
|
415
412
|
/* ------------------------------------------------------------------ talking to rooms */
|
|
416
413
|
|
|
417
|
-
const lobbyOf = (env, game) =>
|
|
414
|
+
const lobbyOf = (env, game) => lobbyFor(env,game);
|
|
418
415
|
const tableOf = (env, game, room) => env.TABLE.get(env.TABLE.idFromName(`${game}/${room}`));
|
|
419
416
|
|
|
420
417
|
/** The rooms of one game with people in them now, as its Lobby last heard (public and named). */
|
|
@@ -554,7 +551,7 @@ export async function agentsTry(env, cat, body) {
|
|
|
554
551
|
const n = Math.round(t.cost.neurons);
|
|
555
552
|
if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
|
|
556
553
|
if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
|
|
557
|
-
await env.DB.batch(rows.filter(Boolean))
|
|
554
|
+
await env.DB.batch(rows.filter(Boolean));
|
|
558
555
|
}
|
|
559
556
|
return {
|
|
560
557
|
ok: true, game: meta.id, mode, model: t.model ?? (mode === 'workers-ai' ? e.HOMIE_BRAIN_MODEL || DEFAULT_MODEL : null), engine: t.engine, provider: t.provider,
|
|
@@ -1118,7 +1115,7 @@ export async function perform(env, cat, a) {
|
|
|
1118
1115
|
if (a.budget !== undefined) {
|
|
1119
1116
|
const day = await brainDay(env);
|
|
1120
1117
|
const next = { neurons: day.budget.neurons, usd: day.budget.usd, ...(a.mode === 'workers-ai' ? { neurons: Math.round(a.budget) } : { usd: Math.round(a.budget * 100) / 100 }) };
|
|
1121
|
-
await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run()
|
|
1118
|
+
await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run();
|
|
1122
1119
|
}
|
|
1123
1120
|
await pushPolicy(env, meta, settings, sv);
|
|
1124
1121
|
const day = await brainDay(env);
|
|
@@ -1342,42 +1339,42 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1342
1339
|
if (path === '/_studio/office' || path === '/_studio/office/') {
|
|
1343
1340
|
const cat = await catalogueOf();
|
|
1344
1341
|
if (request.method !== 'GET') return new Response('method', { status: 405 });
|
|
1345
|
-
if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
|
|
1342
|
+
if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
|
|
1346
1343
|
// A session from before 0.13.0 lives only under /_studio/: carry it to / so the owner's games know them too.
|
|
1347
1344
|
const session = await ownerSession(request, env);
|
|
1348
1345
|
const secure = url.protocol === 'https:' ? '; Secure' : '';
|
|
1349
|
-
return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
|
|
1346
|
+
return (await import('./office-page.mjs')).officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
|
|
1350
1347
|
}
|
|
1351
1348
|
if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
|
|
1352
1349
|
const cat = await catalogueOf();
|
|
1353
1350
|
if (request.method !== 'GET') return new Response('method', { status: 405 });
|
|
1354
|
-
if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
|
|
1351
|
+
if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
|
|
1355
1352
|
return officeShopPage(cat);
|
|
1356
1353
|
}
|
|
1357
1354
|
const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
|
|
1358
1355
|
if (confirm) {
|
|
1359
1356
|
const cat = await catalogueOf();
|
|
1360
|
-
if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'confirm', ask: confirm[1] });
|
|
1357
|
+
if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'confirm', ask: confirm[1] });
|
|
1361
1358
|
const row = await readAsk(env, confirm[1]);
|
|
1362
|
-
if (!row) return confirmPage(cat, null, { missing: true });
|
|
1359
|
+
if (!row) return (await import('./office-page.mjs')).confirmPage(cat, null, { missing: true });
|
|
1363
1360
|
const ask = askView(row, cat, url.origin);
|
|
1364
|
-
if (request.method === 'GET') return confirmPage(cat, ask);
|
|
1361
|
+
if (request.method === 'GET') return (await import('./office-page.mjs')).confirmPage(cat, ask);
|
|
1365
1362
|
if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
|
|
1366
1363
|
let yes = false;
|
|
1367
1364
|
try { yes = String((await request.formData()).get('do') ?? '') === 'yes'; } catch { yes = false; }
|
|
1368
|
-
if (ask.state !== 'pending') return confirmPage(cat, ask);
|
|
1365
|
+
if (ask.state !== 'pending') return (await import('./office-page.mjs')).confirmPage(cat, ask);
|
|
1369
1366
|
if (!yes) {
|
|
1370
1367
|
await env.DB.prepare("UPDATE office_asks SET state = 'cancelled' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
|
|
1371
|
-
return confirmPage(cat, { ...ask, state: 'cancelled' });
|
|
1368
|
+
return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: 'cancelled' });
|
|
1372
1369
|
}
|
|
1373
1370
|
// Claim it first, so a double tap does it once.
|
|
1374
1371
|
await env.DB.prepare("UPDATE office_asks SET state = 'working' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
|
|
1375
1372
|
const claimed = await readAsk(env, ask.id);
|
|
1376
|
-
if (claimed?.state !== 'working') return confirmPage(cat, askView(claimed ?? row, cat, url.origin));
|
|
1373
|
+
if (claimed?.state !== 'working') return (await import('./office-page.mjs')).confirmPage(cat, askView(claimed ?? row, cat, url.origin));
|
|
1377
1374
|
if (ask.action.op === 'shop-release') ask.action.by = await releaseIdentity(request, env);
|
|
1378
1375
|
const result = await perform(env, cat, ask.action);
|
|
1379
1376
|
await env.DB.prepare('UPDATE office_asks SET state = ?2, result = ?3 WHERE id = ?1').bind(ask.id, result.ok ? 'done' : 'failed', JSON.stringify(result).slice(0, 4000)).run();
|
|
1380
|
-
return confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
|
|
1377
|
+
return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
|
|
1381
1378
|
}
|
|
1382
1379
|
return null;
|
|
1383
1380
|
}
|
|
@@ -28,3 +28,19 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
|
|
|
28
28
|
${op==='UPDATE'?`INSERT INTO tool_data_revisions SELECT OLD.namespace,1 WHERE OLD.namespace!=NEW.namespace ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;`:''}
|
|
29
29
|
END;`;}).join('\n')}
|
|
30
30
|
`;
|
|
31
|
+
|
|
32
|
+
export const RUNTIME_INDEX_MIGRATION_FILE='0034_runtime_indexes.sql';
|
|
33
|
+
export const RUNTIME_INDEX_MIGRATION=`-- Older stream registrations used milliseconds; OAuth and dedupe already use seconds.
|
|
34
|
+
UPDATE mcp_kv SET expires=CAST(expires / 1000 AS INTEGER) WHERE expires>100000000000 AND (key GLOB 'tools-stream:*' OR key GLOB 'mcp-event-stream:*');
|
|
35
|
+
CREATE INDEX IF NOT EXISTS mcp_kv_expires ON mcp_kv(expires) WHERE expires IS NOT NULL;
|
|
36
|
+
`;
|
|
37
|
+
|
|
38
|
+
export const EXTRA_RUNTIME_MIGRATIONS={
|
|
39
|
+
"0036_lobby_summaries.sql":`CREATE TABLE IF NOT EXISTS lobby_summaries(game TEXT PRIMARY KEY,rooms TEXT NOT NULL,updated INTEGER NOT NULL);
|
|
40
|
+
`,
|
|
41
|
+
"0035_payment_delivery.sql":`CREATE TABLE IF NOT EXISTS stripe_intake(id TEXT PRIMARY KEY,payload TEXT NOT NULL,origin TEXT NOT NULL,received INTEGER NOT NULL,lease INTEGER NOT NULL DEFAULT 0,done INTEGER);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS stripe_intake_pending ON stripe_intake(lease,received,id) WHERE done IS NULL;
|
|
43
|
+
CREATE INDEX IF NOT EXISTS stripe_intake_done ON stripe_intake(done) WHERE done IS NOT NULL;
|
|
44
|
+
CREATE INDEX IF NOT EXISTS shop_events_at ON shop_events(at);
|
|
45
|
+
`,
|
|
46
|
+
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {STUDIO_VERSION_TAG} from './version.mjs';
|
|
2
|
+
import {playerAncestors} from './embed.mjs';
|
|
3
|
+
export function shellKey(g) {
|
|
4
|
+
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
5
|
+
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
6
|
+
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
7
|
+
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
8
|
+
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
9
|
+
let h = 2166136261;
|
|
10
|
+
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
11
|
+
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
12
|
+
}
|
|
13
|
+
export function shellStandIn(room, key) {
|
|
14
|
+
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
15
|
+
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
16
|
+
}
|
|
17
|
+
export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
|
|
18
|
+
export function frameAncestors(cat) {
|
|
19
|
+
const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
|
|
20
|
+
.map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
|
|
21
|
+
.filter(Boolean).slice(0, 8);
|
|
22
|
+
return ["'self'", ...extra].join(' ');
|
|
23
|
+
}
|
|
24
|
+
export function embedAncestors(cat, origin, preview = false) {
|
|
25
|
+
const normal = playerAncestors(cat);
|
|
26
|
+
if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
|
|
27
|
+
const port = new URL(origin).port;
|
|
28
|
+
return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
|
|
29
|
+
}
|
package/worker/pages.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {ROOM_ID,shellKey,shellStandIn,frameAncestors,embedAncestors} from './page-policy.mjs';
|
|
2
|
+
export {ROOM_ID,shellKey,shellStandIn,frameAncestors,embedAncestors} from './page-policy.mjs';
|
|
1
3
|
import {JOIN_AREA_SHELL_JS} from './join-area.mjs';
|
|
2
4
|
import { appWords, openPath } from './app-format.mjs';
|
|
3
5
|
import { APP_SHELL_JS } from './app-shell.mjs';
|
|
@@ -7,7 +9,7 @@ import { APP_SHELL_JS } from './app-shell.mjs';
|
|
|
7
9
|
*/
|
|
8
10
|
import { playerAncestors, PLAYER_SANDBOX, PLAYER_SIZE } from './embed.mjs';
|
|
9
11
|
import { esc, layout, gameSocialTags } from './site.mjs';
|
|
10
|
-
import { NET_PALETTE } from './room.mjs';
|
|
12
|
+
import { NET_PALETTE } from './room-protocol.mjs';
|
|
11
13
|
import { PARAM_VALUE, PREFS_LIMITS, playParams } from './seats.mjs';
|
|
12
14
|
import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
13
15
|
|
|
@@ -19,26 +21,14 @@ import { STUDIO_VERSION_TAG } from './version.mjs';
|
|
|
19
21
|
* instead of hosting a newer game inside a page that lacks what that game needs (a player ticket, for one).
|
|
20
22
|
* It does not change when only the game's code or art does: that is still a reload of the frame alone.
|
|
21
23
|
*/
|
|
22
|
-
|
|
23
|
-
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
24
|
-
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
25
|
-
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
26
|
-
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
27
|
-
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
28
|
-
let h = 2166136261;
|
|
29
|
-
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
30
|
-
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
31
|
-
}
|
|
24
|
+
|
|
32
25
|
/**
|
|
33
26
|
* The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
|
|
34
27
|
* `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
|
|
35
28
|
* only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
|
|
36
29
|
* work inside that page at all.
|
|
37
30
|
*/
|
|
38
|
-
|
|
39
|
-
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
40
|
-
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
41
|
-
}
|
|
31
|
+
|
|
42
32
|
import { SKILLS } from './agents.mjs';
|
|
43
33
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
44
34
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -49,18 +39,13 @@ import { ARRIVAL_JS, arrivalCard } from './arrival.mjs';
|
|
|
49
39
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
50
40
|
|
|
51
41
|
/** A room code: 1 to 32 letters, digits, - or _. */
|
|
52
|
-
|
|
42
|
+
|
|
53
43
|
|
|
54
44
|
/**
|
|
55
45
|
* Who may put the play page (and so the game) inside a frame: this site, plus the https origins studio.json
|
|
56
46
|
* `site.frameAncestors` names (for example a hub that embeds the game). Anything else is refused by the browser.
|
|
57
47
|
*/
|
|
58
|
-
|
|
59
|
-
const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
|
|
60
|
-
.map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
|
|
61
|
-
.filter(Boolean).slice(0, 8);
|
|
62
|
-
return ["'self'", ...extra].join(' ');
|
|
63
|
-
}
|
|
48
|
+
|
|
64
49
|
|
|
65
50
|
/** What a play, big-screen or watch page answers to a room code it cannot use: said on the page, never a silent public room. */
|
|
66
51
|
export function badRoomPage(cat, g, raw, { screen = false, watch = false } = {}) {
|
|
@@ -998,7 +983,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
998
983
|
try { arrived = performance.getEntriesByType('navigation')[0].type; } catch (e) {}
|
|
999
984
|
askedRoom = kept && (arrived === 'reload' || arrived === 'back_forward') && EMBED_ROOM.test(kept.room || '') ? kept.room : null;
|
|
1000
985
|
}
|
|
1001
|
-
var lobbyQ = [];
|
|
986
|
+
var lobbyQ = ['b=' + encodeURIComponent(roomKey)];
|
|
1002
987
|
if (state.server) lobbyQ.push('server=' + encodeURIComponent(state.server.id));
|
|
1003
988
|
if (/^[A-Za-z0-9_,-]{1,140}$/.test(params.get('not') || '')) lobbyQ.push('not=' + encodeURIComponent(params.get('not')));
|
|
1004
989
|
// Once the game has the screen, the chip says the room's facts for a few seconds, then gets out of the way.
|
|
@@ -1596,12 +1581,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1596
1581
|
}
|
|
1597
1582
|
}());`;
|
|
1598
1583
|
|
|
1599
|
-
|
|
1600
|
-
const normal = playerAncestors(cat);
|
|
1601
|
-
if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
|
|
1602
|
-
const port = new URL(origin).port;
|
|
1603
|
-
return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
|
|
1604
|
-
}
|
|
1584
|
+
|
|
1605
1585
|
|
|
1606
1586
|
export function embedPreview(cat, g, origin) {
|
|
1607
1587
|
const frameOrigin = new URL(origin); frameOrigin.hostname = frameOrigin.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
|
package/worker/parts.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { esc, icon, layout, notFoundPage } from './site.mjs';
|
|
|
17
17
|
const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
18
18
|
const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
|
|
19
19
|
/** A packed part's files: framed by the part's own page and by the hub, so they keep their own frame rule (index.mjs finish). */
|
|
20
|
-
|
|
20
|
+
import {PART_FILES} from './asset-paths.mjs';
|
|
21
|
+
export {PART_FILES} from './asset-paths.mjs';
|
|
21
22
|
export const PARTS_WELL_KNOWN = '/.well-known/homie-parts.json';
|
|
22
23
|
export const isPartsPath = (path) => path === '/openapi.json' || path === '/.well-known/api-catalog' || path === '/parts/catalog.json' || path === '/purchases/keys.json' || path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
|
|
23
24
|
|
|
@@ -2,13 +2,13 @@ import { pendingJobsSQL, definitiveRefusal, REPLAY_LIMIT_MS, recoveryAction } fr
|
|
|
2
2
|
import { orderFact, recordOrderState, releaseClaim } from './purchase-core.mjs';
|
|
3
3
|
import { resumeSettlement } from './settlement-journal.mjs';
|
|
4
4
|
/** Durable provider recordings outlive responses. A verified chain transfer is payment. */
|
|
5
|
-
import { stripeCall, refundPayment, modeOf } from './stripe.mjs';
|
|
5
|
+
import { stripeCall, refundPayment, modeOf, STRIPE_VERSION } from './stripe.mjs';
|
|
6
6
|
import { byId, resourceAvailable } from './purchase-store.mjs';
|
|
7
7
|
export async function saveRecording(env, order, params, options) {
|
|
8
8
|
const chain = params.payment_method_options?.crypto?.transaction_verification_options;
|
|
9
9
|
const id = chain ? `${order.id}:${chain.network}:${chain.transaction_hash}` : options.idempotencyKey;
|
|
10
10
|
await env.DB.prepare("INSERT OR IGNORE INTO purchase_jobs (id,order_id,mode,state,payload,updated_at) VALUES (?1,?2,?3,?4,?5,?6)")
|
|
11
|
-
.bind(id, order.id, order.mode, chain ? 'paid_recording' : 'submitted', JSON.stringify({ params, options: { ...options, idempotencyKey: chain ? chain.transaction_hash : options.idempotencyKey }, chain, startedAt: Date.now() }), Date.now()).run();
|
|
11
|
+
.bind(id, order.id, order.mode, chain ? 'paid_recording' : 'submitted', JSON.stringify({ params, options: { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION, idempotencyKey: chain ? chain.transaction_hash : options.idempotencyKey }, chain, startedAt: Date.now() }), Date.now()).run();
|
|
12
12
|
if (chain) {
|
|
13
13
|
// Only the protocol's verified settlement callback reaches here, never buyer input.
|
|
14
14
|
await orderFact(env, order.id, 'payment', {paid_at: Date.now()}).run();
|
|
@@ -16,6 +16,11 @@ export async function saveRecording(env, order, params, options) {
|
|
|
16
16
|
}
|
|
17
17
|
return id;
|
|
18
18
|
}
|
|
19
|
+
/** Persist the provider id before any follow-up retrieve/cancel/fulfillment call. */
|
|
20
|
+
export async function recordProviderId(env, order, id, pi) {
|
|
21
|
+
if (!/^pi_[A-Za-z0-9]+$/.test(pi.id) || pi.amount !== order.amount || pi.currency !== order.currency || pi.livemode !== (order.mode === 'live')) throw new Error('Provider receipt does not match order');
|
|
22
|
+
await env.DB.prepare('UPDATE purchase_jobs SET payment=?2,updated_at=?3 WHERE id=?1').bind(id,pi.id,Date.now()).run();
|
|
23
|
+
}
|
|
19
24
|
export async function recordProviderResult(env, order, id, pi) {
|
|
20
25
|
if (pi.amount !== order.amount || pi.currency !== order.currency || pi.livemode !== (order.mode === 'live')) throw new Error('Provider receipt does not match order');
|
|
21
26
|
await env.DB.prepare("UPDATE purchase_jobs SET payment = ?2, state = ?3, error = NULL, updated_at = ?4 WHERE id = ?1").bind(id, pi.id, pi.status === 'succeeded' ? 'recorded' : ['canceled','requires_payment_method'].includes(pi.status) ? 'failed' : 'processing', Date.now()).run();
|
|
@@ -37,31 +42,40 @@ export async function recordingFailure(env, id, error) {
|
|
|
37
42
|
await env.DB.prepare("UPDATE purchase_jobs SET state = 'failed', error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1 AND state = 'submitted'").bind(id, String(error.message).slice(0, 500), Date.now()).run();
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
45
|
+
if(error.manualReview) {
|
|
46
|
+
await env.DB.prepare("UPDATE purchase_jobs SET state='manual_review',error=?2,updated_at=?3 WHERE id=?1").bind(id,error.message,Date.now()).run();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
40
49
|
// A failure of our call is not a fact about the original payment.
|
|
41
50
|
await env.DB.prepare("UPDATE purchase_jobs SET error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1")
|
|
42
51
|
.bind(id, String(error.message).slice(0, 500), Date.now()).run();
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
/** Read
|
|
54
|
+
/** Read the recorded object, or recover a lost create response with its original key. */
|
|
46
55
|
export async function readProviderPayment(env, order, job, payment = null) {
|
|
47
|
-
const
|
|
56
|
+
const readOptions = { version: STRIPE_VERSION };
|
|
48
57
|
if (payment && !/^pi_[A-Za-z0-9]+$/.test(payment)) throw new Error('Supply an existing Stripe PaymentIntent id');
|
|
49
58
|
if (job.payment || payment) {
|
|
50
|
-
const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(job.payment ?? payment)}`, null,
|
|
59
|
+
const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(job.payment ?? payment)}`, null, readOptions);
|
|
51
60
|
if (pi.metadata?.order !== order.id) throw new Error('The existing PaymentIntent belongs to another order');
|
|
52
61
|
return pi;
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const {params,options,startedAt}=JSON.parse(job.payload??'{}');
|
|
64
|
+
if(params&&options?.idempotencyKey&&Date.now()-(startedAt??order.created_at)<REPLAY_LIMIT_MS) {
|
|
65
|
+
// If the create response was lost, replay only the original request/key
|
|
66
|
+
// inside Stripe's guaranteed retention window. Never scan the account.
|
|
67
|
+
const version=options.version ?? options.apiVersion;
|
|
68
|
+
if(!version) {
|
|
69
|
+
const error=new Error('Original Stripe API version is missing; reconcile this retained order with an existing PaymentIntent id instead of replaying an uncertain request.');
|
|
70
|
+
error.manualReview=true;throw error;
|
|
71
|
+
}
|
|
72
|
+
const pi=await stripeCall(env,'POST','/v1/payment_intents',params,{...options,version});
|
|
73
|
+
await recordProviderId(env,order,job.id,pi);
|
|
74
|
+
return pi;
|
|
63
75
|
}
|
|
64
|
-
|
|
76
|
+
const error=new Error(`PaymentIntent id missing after the safe replay window for order ${order.id}; reconcile with an existing PaymentIntent id. The order and its claim are retained.`);
|
|
77
|
+
error.manualReview=true;
|
|
78
|
+
throw error;
|
|
65
79
|
}
|
|
66
80
|
async function releaseUnpaid(env, order, job, pi = null) {
|
|
67
81
|
await env.DB.batch([
|
|
@@ -87,18 +101,18 @@ export async function reconcileRecordings(env, orderId = null, dependencies = {}
|
|
|
87
101
|
switch (recoveryAction(observation)) {
|
|
88
102
|
case 'fail':
|
|
89
103
|
if (pi?.status === 'requires_payment_method') {
|
|
90
|
-
pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version:
|
|
104
|
+
pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: STRIPE_VERSION });
|
|
91
105
|
if (pi.status !== 'canceled') throw new Error('The unpaid payment could not be canceled; the claim remains reserved');
|
|
92
106
|
}
|
|
93
107
|
await releaseUnpaid(env, order, job, pi); continue;
|
|
94
|
-
case 'replay': pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version:
|
|
108
|
+
case 'replay': pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION }); break;
|
|
95
109
|
default:
|
|
96
110
|
if (!pi) {
|
|
97
111
|
if (deadline) throw new Error(`Paid transfer still needs recording: order ${order.id}; key ${options.idempotencyKey}`);
|
|
98
|
-
pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version:
|
|
112
|
+
pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION });
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
|
-
if (['requires_action','requires_confirmation','requires_capture','requires_payment_method'].includes(pi.status) || orderId && dependencies.cancel === true && pi.status === 'processing') pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version:
|
|
115
|
+
if (['requires_action','requires_confirmation','requires_capture','requires_payment_method'].includes(pi.status) || orderId && dependencies.cancel === true && pi.status === 'processing') pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: STRIPE_VERSION });
|
|
102
116
|
await recordProviderResult(env, order, job.id, pi);
|
|
103
117
|
if (['canceled','requires_payment_method'].includes(pi.status)) { await releaseUnpaid(env, order, job, pi); continue; }
|
|
104
118
|
if (pi.status !== 'succeeded') {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export let players = null;
|
|
2
|
+
let configured=false;
|
|
3
|
+
/**
|
|
4
|
+
* The player-accounts API, handed over once by a studio's Worker that has it:
|
|
5
|
+
* `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
|
|
6
|
+
*/
|
|
7
|
+
export function usePlayers(api) { configured=true; players = api && typeof api === 'object' ? api : null; }
|
|
8
|
+
|
|
9
|
+
export function defaultPlayers(api) { if(!configured)players=api; }
|