@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.
Files changed (104) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/background-queue.mjs +31 -0
  8. package/lib/build.mjs +4 -1
  9. package/lib/check.mjs +8 -1
  10. package/lib/cloudflare.mjs +65 -23
  11. package/lib/dev.mjs +6 -8
  12. package/lib/local-database-start.mjs +6 -0
  13. package/lib/mcp-tools.mjs +3 -3
  14. package/lib/port-check.mjs +3 -2
  15. package/lib/read-replication.mjs +26 -0
  16. package/lib/scaffold.mjs +30 -10
  17. package/lib/shop.mjs +7 -0
  18. package/lib/static-assets.mjs +27 -0
  19. package/lib/template-history.json +87 -0
  20. package/lib/worker-build.mjs +16 -4
  21. package/lib/worker-config.mjs +10 -1
  22. package/netplay/NETPLAY.md +5 -1
  23. package/netplay/netplay.ts +24 -9
  24. package/package.json +1 -1
  25. package/rules/core.ts +9 -0
  26. package/rules/host.ts +40 -11
  27. package/rules/view.ts +1 -0
  28. package/tools/TOOLS.md +6 -1
  29. package/worker/analytics.mjs +84 -0
  30. package/worker/app-frame.mjs +1 -1
  31. package/worker/app-graphics.mjs +1 -1
  32. package/worker/app-streams.mjs +3 -3
  33. package/worker/asset-paths.mjs +2 -0
  34. package/worker/background-queue.mjs +31 -0
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/built-metadata.mjs +2 -0
  37. package/worker/catalogue.mjs +18 -0
  38. package/worker/chat-page.mjs +1 -1
  39. package/worker/custom-routes.mjs +16 -0
  40. package/worker/embed.mjs +1 -1
  41. package/worker/event-resources.mjs +23 -11
  42. package/worker/function-registry.mjs +4 -0
  43. package/worker/functions.mjs +7 -5
  44. package/worker/gate-routing.mjs +61 -0
  45. package/worker/gate.mjs +106 -91
  46. package/worker/hosted-registry.mjs +11 -0
  47. package/worker/hosted.mjs +3 -17
  48. package/worker/idle-chat.mjs +56 -0
  49. package/worker/index.mjs +56 -2785
  50. package/worker/lazy-objects.mjs +43 -0
  51. package/worker/lobby-limits.mjs +2 -0
  52. package/worker/lobby-peak.mjs +44 -0
  53. package/worker/lobby-shards.mjs +43 -0
  54. package/worker/lobby-store.mjs +35 -0
  55. package/worker/lobby-summary.mjs +28 -0
  56. package/worker/lobby.mjs +319 -0
  57. package/worker/lounge-store.mjs +2 -2
  58. package/worker/lounge.mjs +3 -3
  59. package/worker/match-router.mjs +74 -0
  60. package/worker/mcp-event-streams.mjs +3 -3
  61. package/worker/mcp-events.mjs +4 -4
  62. package/worker/mcp-room.mjs +2 -1
  63. package/worker/mcp-store.mjs +12 -1
  64. package/worker/mcp-tools.mjs +4 -2
  65. package/worker/mcp.mjs +5 -1
  66. package/worker/office.mjs +18 -21
  67. package/worker/operational-schema.mjs +16 -0
  68. package/worker/page-policy.mjs +29 -0
  69. package/worker/pages.mjs +9 -29
  70. package/worker/parts.mjs +2 -1
  71. package/worker/payment-recovery.mjs +33 -19
  72. package/worker/player-registry.mjs +9 -0
  73. package/worker/players.mjs +8 -7
  74. package/worker/purchase-machine.mjs +5 -3
  75. package/worker/reconcile-guard.mjs +22 -0
  76. package/worker/relay-channel.mjs +119 -0
  77. package/worker/request-db.mjs +36 -10
  78. package/worker/responses.mjs +18 -0
  79. package/worker/room-chat.mjs +217 -0
  80. package/worker/room-controls.mjs +296 -0
  81. package/worker/room-lifecycle.mjs +10 -0
  82. package/worker/room-protocol.mjs +197 -0
  83. package/worker/room-sessions.mjs +65 -0
  84. package/worker/room.mjs +54 -740
  85. package/worker/rooms-cache.mjs +19 -0
  86. package/worker/route-auth.mjs +22 -0
  87. package/worker/routes.mjs +670 -0
  88. package/worker/runtime-support.mjs +447 -0
  89. package/worker/seats.mjs +1 -1
  90. package/worker/selling/index.mjs +1 -0
  91. package/worker/selling/runtime.mjs +7 -5
  92. package/worker/service-run.mjs +1 -1
  93. package/worker/session-codec.mjs +24 -0
  94. package/worker/shop-links.mjs +1 -1
  95. package/worker/shop-page.mjs +1 -0
  96. package/worker/shop.mjs +27 -20
  97. package/worker/stats.mjs +9 -7
  98. package/worker/stream-objects.mjs +18 -0
  99. package/worker/stripe-intake.mjs +79 -0
  100. package/worker/stripe.mjs +3 -0
  101. package/worker/table.mjs +1448 -0
  102. package/worker/tool-registry.mjs +10 -0
  103. package/worker/version.mjs +1 -1
  104. 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()+60000).run();
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()+60000).run();
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);
@@ -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 LIKE '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});}
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 LIKE 'mcp-event-hook:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
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
  }});
@@ -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 env.LOBBY.get(env.LOBBY.idFromName(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
+ 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'}});
@@ -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 rows = (await DB.prepare('SELECT key,expires FROM mcp_kv WHERE substr(key,1,?1)=?2 AND key>?3 AND (expires IS NULL OR expires>?4) ORDER BY key LIMIT ?5').bind(prefix.length,prefix,cursor,Math.floor(Date.now()/1000),n+1).all()).results;
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
  };
@@ -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]; const target=new URL(url);
82
- if(!rule || target.protocol!=='https:' || target.origin!==rule.origin || (!caller.owner&&!rule.tools?.includes(definition.name))) throw new Error('secret capability refused');
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
- await pushToolList(env,cat,tools,origin);
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
- import { confirmPage, lockedPage, officePage } from './office-page.mjs';
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
- let players = null;
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) => env.LOBBY.get(env.LOBBY.idFromName(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)).catch(() => {});
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().catch(() => {});
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
- export function shellKey(g) {
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
- export function shellStandIn(room, key) {
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
- export const ROOM_ID = /^[A-Za-z0-9_-]{1,32}$/;
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
- export function frameAncestors(cat) {
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
- export function embedAncestors(cat, origin, preview = false) {
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
- export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
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 existing objects first. Listing avoids the search index's delayed negative answers. */
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 options = { version: '2026-07-29.preview' };
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, options);
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
- let cursor;
55
- for (let page = 0; page < 100; page++) {
56
- const result = await stripeCall(env, 'GET', '/v1/payment_intents', { limit: 100, created: { gte: Math.floor(order.created_at / 1000) - 60 }, ...(cursor ? { starting_after: cursor } : {}) }, options);
57
- if (!Array.isArray(result.data)) throw new Error('Stripe did not return an existing-payment list');
58
- const found = result.data.find((pi) => pi.metadata?.order === order.id);
59
- if (found) return found;
60
- if (!result.has_more) return null;
61
- cursor = result.data.at(-1)?.id;
62
- if (!cursor) throw new Error('Stripe payment list has no continuation');
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
- throw new Error(`Existing payment scan exceeded 100 pages for order ${order.id}; the owner must reconcile this order.`);
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: '2026-07-29.preview' });
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: '2026-07-29.preview' }); break;
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: '2026-07-29.preview' });
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: '2026-07-29.preview' });
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; }