@homie-rocks/studio 0.53.0 → 0.55.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 (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
package/worker/office.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {databaseBinding, ORIGINAL_DB} from './request-db.mjs';
1
2
  import { selling } from './extensions.mjs';
2
3
  import { purchaseOrderIfPresent } from './shop.mjs';
3
4
  import { toolIdentity } from './tool-identity.mjs';
@@ -62,13 +63,13 @@ import { toolIdentity } from './tool-identity.mjs';
62
63
  * from an office key it is ALWAYS an ask (the AI may only propose one).
63
64
  */
64
65
  import { seatsOf } from './seats.mjs';
65
- import { diagnosticsOf, gatePlayers, roomLayout } from './gate.mjs';
66
+ import { diagnosticsOf, gatePlayers, roomLayout } from './gate-routing.mjs';
66
67
  import { PUBLIC_SERVER, SERVER_LIMITS, checkServer, levelName, narrows, policyOf, roomServer, rowFor, serverView, serversOf, serverPassCookie, writeServer } from './servers.mjs';
67
68
  import { BRAIN_BUDGET, aiName, brainFor, fillSpot, passById, passCreate, passList, passRevoke, think } from './agents.mjs';
68
69
  import { DEFAULT_MODEL, NEURONS_PER_M, OWNER_MODEL, vocabularyOf } from './brain.mjs';
69
70
  import { OWNER_COOKIE, cookieValues, counter, ownerAllowed, ownerSession, today } from './stats.mjs';
70
71
  import { esc, layout, notFoundPage } from './site.mjs';
71
- import { confirmPage, lockedPage, officePage } from './office-page.mjs';
72
+
72
73
  import { CHAT_MODES, CHAT_WHO, checkChatRules, publicChat } from './chat.mjs';
73
74
  import { chatDay, chatOf, chatRowsOf, clearChatRules, dismissReport, reportsOf, writeChatRules } from './chat-store.mjs';
74
75
  import { checkRelease, performRelease, checkRefund, describeRefund, officeShopPage, ordersCsv, performRefund, shopOffice } from './shop.mjs';
@@ -141,12 +142,8 @@ export function canonical(v) {
141
142
 
142
143
  /* ------------------------------------------------------------------ player accounts (a seam) */
143
144
 
144
- let players = null;
145
- /**
146
- * The player-accounts API, handed over once by a studio's Worker that has it:
147
- * `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
148
- */
149
- export function usePlayers(api) { players = api && typeof api === 'object' ? api : null; }
145
+ import {players} from './player-registry.mjs';
146
+ export {usePlayers} from './player-registry.mjs';
150
147
 
151
148
  async function playerOf(env, id) {
152
149
  if (!players?.get || !id) return null;
@@ -170,6 +167,7 @@ export async function accountSub(request, env) {
170
167
 
171
168
  /** The owner: the signed-in owner's browser session, or (with player accounts) the owner's own account. */
172
169
  export async function isOwner(request, env) {
170
+ if(toolIdentity(request)?.owner)return true;
173
171
  if ((await ownerAllowed(request, env, { kinds: ['session'] })) === 'session') return true;
174
172
  try { return players?.isOwner ? (await players.isOwner(request, env)) === true : false; } catch { return false; }
175
173
  }
@@ -181,17 +179,17 @@ const settingsCache = new WeakMap();
181
179
  /** Every game's live settings (D1 office_games), read at most every 5 s per Worker instance. Empty before migration 0005. */
182
180
  export async function settingsOf(env, { fresh = false } = {}) {
183
181
  if (!env?.DB || env.HOMIE_PREVIEW === '1') return new Map();
184
- const hit = settingsCache.get(env.DB);
185
- if (!fresh && hit && Date.now() - hit.at < 5000) return hit.map;
182
+ const hit = settingsCache.get(databaseBinding(env.DB));
183
+ if (!env.DB[ORIGINAL_DB] && !fresh && hit && Date.now() - hit.at < 5000) return hit.map;
186
184
  let map = new Map();
187
185
  try {
188
186
  const { results } = await env.DB.prepare('SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500').all();
189
187
  map = new Map((results ?? []).map((r) => [r.game, r]));
190
188
  } catch { map = new Map(); }
191
- settingsCache.set(env.DB, { at: Date.now(), map });
189
+ settingsCache.set(databaseBinding(env.DB), { at: Date.now(), map });
192
190
  return map;
193
191
  }
194
- const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(env.DB); };
192
+ const forgetSettings = (env) => { if (env?.DB) settingsCache.delete(databaseBinding(env.DB)); };
195
193
 
196
194
  /** A game's launch state: the owner's (D1), else its game.json `launch`, else public. A Preview enforces none. */
197
195
  export function launchOf(meta, settings, env) {
@@ -221,7 +219,7 @@ function hexBytes(hex) { const out = new Uint8Array(hex.length / 2); for (let i
221
219
  /** The studio's office secret (a random 32 bytes in D1 meta, made on first use), as an HMAC key; null without D1. */
222
220
  async function officeKey(env) {
223
221
  if (!env?.DB) return null;
224
- if (keys.has(env.DB)) return keys.get(env.DB);
222
+ if (keys.has(databaseBinding(env.DB))) return keys.get(databaseBinding(env.DB));
225
223
  let hex = null;
226
224
  try {
227
225
  hex = (await env.DB.prepare("SELECT value FROM meta WHERE key = 'office_key'").first())?.value ?? null;
@@ -232,7 +230,7 @@ async function officeKey(env) {
232
230
  } catch { hex = null; }
233
231
  if (!/^[a-f0-9]{64}$/.test(String(hex))) return null;
234
232
  const key = await crypto.subtle.importKey('raw', hexBytes(hex), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
235
- keys.set(env.DB, key);
233
+ keys.set(databaseBinding(env.DB), key);
236
234
  return key;
237
235
  }
238
236
  async function mac(key, text) { return b64url(new Uint8Array(await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(text)))); }
@@ -330,6 +328,8 @@ async function inviteOpen(env, id) {
330
328
  */
331
329
  export async function accessOf(request, env, game, launch) {
332
330
  if (launch === 'public') return { ok: true, sub: null, owner: false };
331
+ const caller=toolIdentity(request);
332
+ if(caller?.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.testApp===game)return {ok:true,sub:null,owner:false};
333
333
  if (await isOwner(request, env)) return { ok: true, sub: 'o', owner: true };
334
334
  if (launch === 'invite') {
335
335
  const pass = await passOf(request, env, game);
@@ -550,7 +550,7 @@ export async function agentsTry(env, cat, body) {
550
550
  const n = Math.round(t.cost.neurons);
551
551
  if (n > 0) rows.push(counter(env, { metric: 'brain-neurons', subject: meta.id, n }));
552
552
  if (t.cost.micros >= 1) rows.push(counter(env, { metric: 'brain-microdollars', subject: meta.id, n: Math.round(t.cost.micros) }));
553
- await env.DB.batch(rows.filter(Boolean)).catch(() => {});
553
+ await env.DB.batch(rows.filter(Boolean));
554
554
  }
555
555
  return {
556
556
  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,
@@ -1114,7 +1114,7 @@ export async function perform(env, cat, a) {
1114
1114
  if (a.budget !== undefined) {
1115
1115
  const day = await brainDay(env);
1116
1116
  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 }) };
1117
- await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run().catch(() => {});
1117
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('brain_budget', ?1)").bind(JSON.stringify(next)).run();
1118
1118
  }
1119
1119
  await pushPolicy(env, meta, settings, sv);
1120
1120
  const day = await brainDay(env);
@@ -1338,55 +1338,110 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
1338
1338
  if (path === '/_studio/office' || path === '/_studio/office/') {
1339
1339
  const cat = await catalogueOf();
1340
1340
  if (request.method !== 'GET') return new Response('method', { status: 405 });
1341
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
1341
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
1342
1342
  // A session from before 0.13.0 lives only under /_studio/: carry it to / so the owner's games know them too.
1343
1343
  const session = await ownerSession(request, env);
1344
1344
  const secure = url.protocol === 'https:' ? '; Secure' : '';
1345
- return officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
1345
+ return (await import('./office-page.mjs')).officePage(cat, session ? { 'set-cookie': `${OWNER_COOKIE}=${session}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${30 * 24 * 3600}${secure}` } : {});
1346
1346
  }
1347
1347
  if (path === '/_studio/office/shop' || path === '/_studio/office/shop/') {
1348
1348
  const cat = await catalogueOf();
1349
1349
  if (request.method !== 'GET') return new Response('method', { status: 405 });
1350
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'office' });
1350
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'office' });
1351
1351
  return officeShopPage(cat);
1352
1352
  }
1353
1353
  const confirm = /^\/_studio\/confirm\/(ask_[a-f0-9]{16})\/?$/.exec(path);
1354
1354
  if (confirm) {
1355
1355
  const cat = await catalogueOf();
1356
- if (!(await isOwner(request, env))) return lockedPage(cat, { what: 'confirm', ask: confirm[1] });
1356
+ if (!(await isOwner(request, env))) return (await import('./office-page.mjs')).lockedPage(cat, { what: 'confirm', ask: confirm[1] });
1357
1357
  const row = await readAsk(env, confirm[1]);
1358
- if (!row) return confirmPage(cat, null, { missing: true });
1358
+ if (!row) return (await import('./office-page.mjs')).confirmPage(cat, null, { missing: true });
1359
1359
  const ask = askView(row, cat, url.origin);
1360
- if (request.method === 'GET') return confirmPage(cat, ask);
1360
+ if (request.method === 'GET') return (await import('./office-page.mjs')).confirmPage(cat, ask);
1361
1361
  if (request.method !== 'POST' || !sameOrigin(request, url)) return new Response('Not allowed', { status: 403 });
1362
1362
  let yes = false;
1363
1363
  try { yes = String((await request.formData()).get('do') ?? '') === 'yes'; } catch { yes = false; }
1364
- if (ask.state !== 'pending') return confirmPage(cat, ask);
1364
+ if (ask.state !== 'pending') return (await import('./office-page.mjs')).confirmPage(cat, ask);
1365
1365
  if (!yes) {
1366
1366
  await env.DB.prepare("UPDATE office_asks SET state = 'cancelled' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
1367
- return confirmPage(cat, { ...ask, state: 'cancelled' });
1367
+ return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: 'cancelled' });
1368
1368
  }
1369
1369
  // Claim it first, so a double tap does it once.
1370
1370
  await env.DB.prepare("UPDATE office_asks SET state = 'working' WHERE id = ?1 AND state = 'pending'").bind(ask.id).run();
1371
1371
  const claimed = await readAsk(env, ask.id);
1372
- if (claimed?.state !== 'working') return confirmPage(cat, askView(claimed ?? row, cat, url.origin));
1372
+ if (claimed?.state !== 'working') return (await import('./office-page.mjs')).confirmPage(cat, askView(claimed ?? row, cat, url.origin));
1373
1373
  if (ask.action.op === 'shop-release') ask.action.by = await releaseIdentity(request, env);
1374
1374
  const result = await perform(env, cat, ask.action);
1375
1375
  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();
1376
- return confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
1376
+ return (await import('./office-page.mjs')).confirmPage(cat, { ...ask, state: result.ok ? 'done' : 'failed', result });
1377
1377
  }
1378
1378
  return null;
1379
1379
  }
1380
1380
 
1381
- /** Asset-only capability: cannot authenticate records, tools or room sockets. */
1382
- export async function appAssetTicket(env, app, ticket) {
1383
- const key=await officeKey(env);if(!key)return null;
1384
- if(ticket===undefined) {
1385
- const exp=(Date.now()+5*60_000).toString(36);
1386
- return `${exp}.${(await mac(key,`app-assets|${app}|${exp}`)).slice(0,32)}`;
1381
+ /** Encrypted, HMAC-authenticated asset capability. Session hashes never enter URLs in plaintext.
1382
+ * Every use checks revocation, grants and role-link rotation in one D1 statement.
1383
+ */
1384
+ export async function appAssetTicket(env, app, ticket, {request,meta,launch=meta?.launch??'public',expectedRole,purpose='asset',renew=false}={}) {
1385
+ const key=await officeKey(env),issuing=ticket===undefined,denied=issuing?null:false;
1386
+ if(!key||!meta)return denied;
1387
+ const decode=text=>Uint8Array.from(atob(text.replaceAll('-','+').replaceAll('_','/')),c=>c.charCodeAt(0));
1388
+ const cipher=await crypto.subtle.importKey('raw',decode(await mac(key,'app-assets-encryption-v1')),'AES-GCM',false,['encrypt','decrypt']);
1389
+ let data;
1390
+ if(issuing) {
1391
+ if(!request)return null;
1392
+ const url=new URL(request.url),{appRole}=await import('./app-format.mjs');
1393
+ const role=appRole(meta,url),rights=meta.roles?.[role];if(!rights)return null;
1394
+ const owner=cookieValues(request,OWNER_COOKIE)[0],player=cookieValues(request,'studio_player')[0],pass=cookieValues(request,passCookie(app))[0],caller=toolIdentity(request);
1395
+ data={purpose,exp:Date.now()+5*60_000,role,rights:await sha256(JSON.stringify(rights)),hash:null,pass:pass?await sha256(pass):null,connection:null,person:null};
1396
+ if(owner||player){data.kind=owner?'owner':'player';data.hash=await sha256(owner??player);}
1397
+ else if(caller){
1398
+ if(caller.localTest&&(env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.roles?.[app]?.includes(role)){data.kind='test';data.testApp=caller.testApp??app;}
1399
+ else {data.kind='tool';data.connection=caller.connection??null;data.person=caller.id??null;if(data.person?.startsWith('office-'))data.hash=data.person.slice(7);}
1400
+ } else if(launch==='invite'&&!rights.signIn&&pass){data.kind='pass';}
1401
+ else {
1402
+ const session=await (await import('./local-test.mjs')).localSession(request,env);
1403
+ if(session?.app!==app||session.role!==role)return null;
1404
+ data.kind='test';data.testApp=app;data.exp=Math.min(data.exp,session.expires??data.exp);
1405
+ }
1406
+ } else {
1407
+ try {
1408
+ const [iv,payload,signature,...extra]=ticket.split('.');
1409
+ if(extra.length||!signature||!same(await mac(key,`app-assets|${app}|${iv}|${payload}`),signature))return false;
1410
+ data=JSON.parse(new TextDecoder().decode(await crypto.subtle.decrypt({name:'AES-GCM',iv:decode(iv)},cipher,decode(payload))));
1411
+ }catch{return false;}
1387
1412
  }
1388
- const m=/^([a-z0-9]+)\.([A-Za-z0-9_-]{32})$/.exec(ticket);
1389
- return Boolean(m && parseInt(m[1],36)>Date.now() && same((await mac(key,`app-assets|${app}|${m[1]}`)).slice(0,32),m[2]));
1413
+ if(data.kind==='test') {
1414
+ const session=request&&await (await import('./local-test.mjs')).localSession(request,env);
1415
+ if(!issuing&&(!session||session.app!==app||session.role!==data.role||(session.scope??null)!==data.testScope))return denied;
1416
+ if(issuing)data.testScope=session?.scope??toolIdentity(request)?.testScope??null;
1417
+ }
1418
+ if(data.purpose!==purpose||(purpose==='handoff'&&!['owner','player'].includes(data.kind)))return denied;
1419
+ if(expectedRole&&data.role!==expectedRole)return denied;
1420
+ if(!Number.isSafeInteger(data.exp)||data.exp<=Date.now()||data.exp>Date.now()+5*60_000||!meta.roles?.[data.role]||data.rights!==await sha256(JSON.stringify(meta.roles[data.role])))return denied;
1421
+ const state=await env.DB.prepare(`SELECT
1422
+ (SELECT token FROM app_role_links WHERE app=?1 AND role=?2) link,
1423
+ EXISTS(SELECT 1 FROM stats_keys WHERE hash=?3 AND kind='session' AND expires_at>?4) owner,
1424
+ EXISTS(SELECT 1 FROM office_passes p JOIN office_invites i ON i.id=p.invite WHERE p.hash=?5 AND p.game=?1 AND p.expires_at>?4 AND i.revoked=0) pass,
1425
+ (SELECT p.id FROM players p WHERE p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4)) player,
1426
+ EXISTS(SELECT 1 FROM players p WHERE p.owner=1 AND (p.id=(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4) OR p.id=?6)) playerOwner,
1427
+ EXISTS(SELECT 1 FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player IN (?6,(SELECT player FROM player_sessions WHERE hash=?3 AND kind='session' AND expires_at>?4))) granted,
1428
+ EXISTS(SELECT 1 FROM mcp_connections WHERE id=?7 AND person=?6 AND revoked=0) connected
1429
+ `).bind(app,data.role,data.hash??null,Date.now(),data.pass??null,data.person??null,data.connection??null).first();
1430
+ if(issuing&&purpose==='asset'&&launch==='invite'&&!meta.roles[data.role].signIn&&state.pass)data.kind='pass';
1431
+ const gate=state.owner||state.playerOwner||launch==='public'||launch==='invite'&&state.pass;
1432
+ const roleAllowed=!meta.roles[data.role].signIn||state.owner||state.playerOwner||state.granted;
1433
+ const authorized=data.kind==='owner'?state.owner:data.kind==='pass'?launch==='invite'&&!meta.roles[data.role].signIn&&state.pass:data.kind==='player'?state.player&&gate&&roleAllowed:data.kind==='tool'?state.connected&&gate&&roleAllowed:data.kind==='test'?data.testApp===app:false;
1434
+ if(!authorized)return denied;
1435
+ const link=state.link?await sha256(state.link):null;
1436
+ if(!issuing) {
1437
+ if(data.link!==link)return denied;
1438
+ if(!renew||purpose!=='handoff')return true;
1439
+ data.exp=Date.now()+5*60_000;
1440
+ }
1441
+ data.link=link;
1442
+ const iv=crypto.getRandomValues(new Uint8Array(12)),ivText=b64url(iv);
1443
+ const payload=b64url(new Uint8Array(await crypto.subtle.encrypt({name:'AES-GCM',iv},cipher,new TextEncoder().encode(JSON.stringify(data)))));
1444
+ return `${ivText}.${payload}.${await mac(key,`app-assets|${app}|${ivText}|${payload}`)}`;
1390
1445
  }
1391
1446
 
1392
1447
  /** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
@@ -1404,3 +1459,19 @@ export async function checkSessionTicket(env, data, token) {
1404
1459
  return session;
1405
1460
  }catch{return null;}
1406
1461
  }
1462
+
1463
+ /** Same-origin role handoff for opening another screen, never the long-lived role URL secret. */
1464
+ export async function appRoleSession(response,request,env,meta,launch) {
1465
+ if(meta.kind!=='app')return response;
1466
+ const {appRole}=await import('./app-format.mjs'),role=appRole(meta,new URL(request.url));
1467
+ if(!meta.roles?.[role]?.signIn)return response;
1468
+ const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
1469
+ const ticket=(handoff&&await appAssetTicket(env,meta.id,handoff,{request,meta,launch,purpose:'handoff',expectedRole:role,renew:true}))||await appAssetTicket(env,meta.id,undefined,{request,meta,launch,purpose:'handoff'});
1470
+ if(ticket) {
1471
+ response=new Response(response.body,{status:response.status,statusText:response.statusText,headers:response.headers});
1472
+ // LAN dev uses HTTP: a Secure cookie is discarded before the shell removes access.
1473
+ const secure=new URL(request.url).protocol==='https:'?'; Secure':'';
1474
+ response.headers.append('set-cookie',`studio_app_${meta.id}_${role}=${ticket}; Path=/${meta.id}/; Max-Age=300; HttpOnly${secure}; SameSite=Strict`);
1475
+ }
1476
+ return response;
1477
+ }
@@ -0,0 +1,44 @@
1
+ export const OPERATIONAL_MIGRATION_FILE='0031_request_and_tool_notifications.sql';
2
+ export const OPERATIONAL_MIGRATION=`
3
+ -- Removed features are cleaned once, never on the request path.
4
+ DELETE FROM meta WHERE key GLOB 'referral_*' OR key GLOB 'referral-*';
5
+ INSERT OR IGNORE INTO meta(key,value) VALUES('tool-data-revision','0');
6
+ ${['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER tool_data_live_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
7
+ UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='tool-data-revision';
8
+ END;`).join('\n')}
9
+ `;
10
+
11
+ export const CACHE_MIGRATION_FILE='0032_metadata_revisions.sql';
12
+ export const CACHE_MIGRATION=`
13
+ INSERT OR IGNORE INTO meta(key,value) VALUES('metadata-revision','0');
14
+ ${['office_games','servers','chat_rules'].flatMap(table=>['INSERT','UPDATE','DELETE'].map(op=>`CREATE TRIGGER ${table}_revision_${op.toLowerCase()} AFTER ${op} ON ${table} BEGIN
15
+ UPDATE meta SET value=CAST(value AS INTEGER)+1 WHERE key='metadata-revision';
16
+ END;`)).join('\n')}
17
+ `;
18
+ export const TOOL_NAMESPACE_MIGRATION_FILE='0033_tool_namespace_revisions.sql';
19
+ export const TOOL_NAMESPACE_MIGRATION=`
20
+ DROP TRIGGER tool_data_live_insert;
21
+ DROP TRIGGER tool_data_live_update;
22
+ DROP TRIGGER tool_data_live_delete;
23
+ DELETE FROM meta WHERE key='tool-data-revision';
24
+ CREATE TABLE tool_data_revisions(namespace TEXT PRIMARY KEY,revision INTEGER NOT NULL);
25
+ CREATE TABLE app_tool_namespaces(app TEXT NOT NULL,namespace TEXT NOT NULL,PRIMARY KEY(app,namespace));
26
+ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';return `CREATE TRIGGER tool_data_namespace_${op.toLowerCase()} AFTER ${op} ON tool_data BEGIN
27
+ INSERT INTO tool_data_revisions VALUES(${row}.namespace,1) ON CONFLICT(namespace) DO UPDATE SET revision=revision+1;
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
+ END;`;}).join('\n')}
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
+ "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);
40
+ CREATE INDEX IF NOT EXISTS stripe_intake_pending ON stripe_intake(lease,received,id) WHERE done IS NULL;
41
+ CREATE INDEX IF NOT EXISTS stripe_intake_done ON stripe_intake(done) WHERE done IS NOT NULL;
42
+ CREATE INDEX IF NOT EXISTS shop_events_at ON shop_events(at);
43
+ `,
44
+ };
@@ -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 } = {}) {
@@ -494,7 +479,13 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
494
479
  ui.querySelector('[data-room-code]').textContent = labelOf(room) + (boot.embed ? ' \u00b7 Site' : '');
495
480
  ui.querySelector('[data-room-label]').textContent = labelOf(room);
496
481
  ui.querySelector('[data-room-link]').textContent = LOCAL ? 'Deploy to share: this preview address works on this computer only.' : link.replace(/^https?:\/\//, '');
497
- ui.querySelector('[data-bigscreen]').href = '/' + boot.game + '/tv?room=' + encodeURIComponent(room);
482
+ var tv = new URL('/' + boot.game + '/tv', location.origin);
483
+ tv.searchParams.set('room', room);
484
+ if (boot.kind === 'app') {
485
+ var context = new URLSearchParams(location.search);
486
+ ['role'].forEach(function (key) { if (context.has(key)) tv.searchParams.set(key, context.get(key)); });
487
+ }
488
+ ui.querySelector('[data-bigscreen]').href = tv.pathname + tv.search;
498
489
  toggle.addEventListener('click', function (e) { e.stopPropagation(); open(sheet.hidden); });
499
490
  ui.querySelector('[data-invite]').addEventListener('click', function () {
500
491
  var data = { title: boot.name, text: (boot.appWords ? boot.appWords.open : 'Play') + ' ' + boot.name + ' with me: join my room.', url: link };
@@ -1590,12 +1581,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
1590
1581
  }
1591
1582
  }());`;
1592
1583
 
1593
- export function embedAncestors(cat, origin, preview = false) {
1594
- const normal = playerAncestors(cat);
1595
- if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
1596
- const port = new URL(origin).port;
1597
- return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
1598
- }
1584
+
1599
1585
 
1600
1586
  export function embedPreview(cat, g, origin) {
1601
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; }