@homie-rocks/studio 0.54.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 (73) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/lib/background-queue.mjs +31 -0
  3. package/lib/build.mjs +4 -1
  4. package/lib/cloudflare.mjs +16 -1
  5. package/lib/read-replication.mjs +26 -0
  6. package/lib/scaffold.mjs +28 -10
  7. package/lib/shop.mjs +7 -0
  8. package/lib/static-assets.mjs +27 -0
  9. package/lib/template-history.json +42 -0
  10. package/lib/worker-build.mjs +13 -4
  11. package/lib/worker-config.mjs +3 -1
  12. package/package.json +1 -1
  13. package/worker/analytics.mjs +84 -0
  14. package/worker/app-frame.mjs +1 -1
  15. package/worker/app-graphics.mjs +1 -1
  16. package/worker/app-streams.mjs +3 -3
  17. package/worker/asset-paths.mjs +2 -0
  18. package/worker/background-queue.mjs +30 -0
  19. package/worker/build-id.mjs +1 -1
  20. package/worker/built-metadata.mjs +2 -0
  21. package/worker/catalogue.mjs +18 -0
  22. package/worker/chat-page.mjs +1 -1
  23. package/worker/embed.mjs +1 -1
  24. package/worker/event-resources.mjs +23 -11
  25. package/worker/function-registry.mjs +4 -0
  26. package/worker/functions.mjs +7 -5
  27. package/worker/gate-routing.mjs +61 -0
  28. package/worker/gate.mjs +12 -68
  29. package/worker/hosted-registry.mjs +11 -0
  30. package/worker/hosted.mjs +3 -17
  31. package/worker/index.mjs +54 -2785
  32. package/worker/lazy-objects.mjs +40 -0
  33. package/worker/lobby-limits.mjs +2 -0
  34. package/worker/lobby.mjs +251 -0
  35. package/worker/lounge.mjs +3 -3
  36. package/worker/mcp-event-streams.mjs +3 -3
  37. package/worker/mcp-events.mjs +4 -4
  38. package/worker/mcp-store.mjs +12 -1
  39. package/worker/mcp.mjs +5 -1
  40. package/worker/office.mjs +16 -20
  41. package/worker/operational-schema.mjs +14 -0
  42. package/worker/page-policy.mjs +29 -0
  43. package/worker/pages.mjs +8 -28
  44. package/worker/parts.mjs +2 -1
  45. package/worker/payment-recovery.mjs +33 -19
  46. package/worker/player-registry.mjs +9 -0
  47. package/worker/players.mjs +8 -7
  48. package/worker/purchase-machine.mjs +5 -3
  49. package/worker/reconcile-guard.mjs +22 -0
  50. package/worker/request-db.mjs +35 -10
  51. package/worker/responses.mjs +18 -0
  52. package/worker/room-chat.mjs +216 -0
  53. package/worker/room-controls.mjs +296 -0
  54. package/worker/room-lifecycle.mjs +10 -0
  55. package/worker/room-protocol.mjs +197 -0
  56. package/worker/room.mjs +38 -731
  57. package/worker/rooms-cache.mjs +19 -0
  58. package/worker/routes.mjs +667 -0
  59. package/worker/runtime-support.mjs +445 -0
  60. package/worker/seats.mjs +1 -1
  61. package/worker/selling/index.mjs +1 -0
  62. package/worker/selling/runtime.mjs +7 -5
  63. package/worker/service-run.mjs +1 -1
  64. package/worker/shop-links.mjs +1 -1
  65. package/worker/shop-page.mjs +1 -0
  66. package/worker/shop.mjs +27 -20
  67. package/worker/stats.mjs +7 -6
  68. package/worker/stream-objects.mjs +18 -0
  69. package/worker/stripe-intake.mjs +79 -0
  70. package/worker/stripe.mjs +3 -0
  71. package/worker/table.mjs +1212 -0
  72. package/worker/tool-registry.mjs +10 -0
  73. package/worker/version.mjs +1 -1
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 } = {}) {
@@ -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; }
@@ -25,12 +25,12 @@ import { toolIdentity } from './tool-identity.mjs';
25
25
  * THE OWNER sees counts and names (`players.count`, `players.list`), never a passkey, a session or an email.
26
26
  * A player sees, downloads (/api/player/export) and deletes (/api/player/delete) everything of theirs.
27
27
  */
28
- import { handleFor } from './room.mjs';
28
+ import { handleFor } from './room-protocol.mjs';
29
29
  import { isAiName } from './agents.mjs';
30
30
  import { cookieValue } from './stats.mjs';
31
31
  import { adoptGuest, bumpPlayerStats, dropPlayerData, fall, fallenList, getSave, listSaves, playerData, readPlayerStats, SAVE_LIMITS, savesSummary, wipeSaves, writeSaves } from './saves.mjs';
32
32
  import { bytesToB64url, randomToken, sha256Hex, verifyAssertion, verifyRegistration } from './webauthn.mjs';
33
- import { ACCOUNT_JS, accountPage } from './account-page.mjs';
33
+
34
34
  import { adoptShopStatements, migrated as shopMigrated, forgetPlayerShop, livePurchases, shopDataOf } from './shop-store.mjs';
35
35
  import { forgetPlayerHistory, playerHistory } from './lounge-store.mjs';
36
36
 
@@ -230,7 +230,7 @@ async function sessionOf(request, env, url = new URL(request.url)) {
230
230
  writes.push(env.DB.prepare('UPDATE player_sessions SET expires_at = ?2 WHERE hash = ?1').bind(hash, now + days * DAY));
231
231
  session.cookies.push(sessionCookie(url, token, days));
232
232
  }
233
- if (writes.length) await env.DB.batch(writes).catch(() => {});
233
+ if (writes.length) await env.DB.batch(writes);
234
234
  return session;
235
235
  }
236
236
 
@@ -240,6 +240,8 @@ async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlaye
240
240
  if (addressLimit !== null && limited(`${counter}:${ip}`, addressLimit, HOUR)) return { error: fail(429, 'rate', 'too many new players from this address; try again later') };
241
241
  const capped = (counter !== 'shop-new' || env.PLAYER_LIMIT_DAILY !== undefined) && await dailyCapReached(env);
242
242
  if (capped) return { error: fail(503, 'busy', 'this studio has taken all the new players it takes in one day; try again tomorrow') };
243
+ // Studios without scheduled functions still reclaim idle guests on this write path.
244
+ if (Math.random() < 0.02) await pruneGuests(env);
243
245
  const id = newPlayerId();
244
246
  const now = Date.now();
245
247
  const name = handleFor(id);
@@ -248,7 +250,6 @@ async function newGuest(request, env, url, addressLimit = PLAYER_LIMITS.newPlaye
248
250
  env.DB.prepare('INSERT INTO players (id, name, named, guest, owner, created_at, seen_at) VALUES (?1, ?2, 0, 1, 0, ?3, ?3)').bind(id, name, now),
249
251
  s.statement,
250
252
  ]);
251
- if (Math.random() < 0.02) pruneGuests(env).catch(() => {});
252
253
  return { session: { player: { id, name, named: false, guest: true, owner: false, since: now }, cookies: [s.cookie], fresh: true } };
253
254
  }
254
255
 
@@ -472,12 +473,12 @@ async function gameOf(catalogueOf, id) {
472
473
  export async function playerRoutes(request, env, ctx, url, { catalogueOf }) {
473
474
  const path = url.pathname;
474
475
  const read = request.method === 'GET' || request.method === 'HEAD';
475
- if (path === '/_homie/account.js') return new Response(ACCOUNT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
476
+ if (path === '/_homie/account.js') return new Response((await import('./account-page.mjs')).ACCOUNT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8', 'cache-control': 'public, max-age=300', 'x-content-type-options': 'nosniff' } });
476
477
  if (path === '/account') return Response.redirect(`${url.origin}/account/${url.search}`, 301);
477
478
  if (path === '/account/') {
478
479
  if (!read) return fail(405, 'method', 'GET only');
479
480
  const cat = await catalogueOf();
480
- return accountPage(cat, { origin: url.origin, next: nextOf(url.searchParams.get('next')), owner: OWNER_KEY.test(url.searchParams.get('owner') ?? '') ? url.searchParams.get('owner') : null, link: LINK.test(url.searchParams.get('k') ?? '') ? url.searchParams.get('k') : null, mode: ['verify', 'recover'].includes(url.searchParams.get('do')) ? url.searchParams.get('do') : null, features: features(env) });
481
+ return (await import('./account-page.mjs')).accountPage(cat, { origin: url.origin, next: nextOf(url.searchParams.get('next')), owner: OWNER_KEY.test(url.searchParams.get('owner') ?? '') ? url.searchParams.get('owner') : null, link: LINK.test(url.searchParams.get('k') ?? '') ? url.searchParams.get('k') : null, mode: ['verify', 'recover'].includes(url.searchParams.get('do')) ? url.searchParams.get('do') : null, features: features(env) });
481
482
  }
482
483
  if (!path.startsWith('/api/player/')) return null;
483
484
  const f = features(env);
@@ -737,7 +738,7 @@ async function apiRoute(request, env, ctx, url, parts, { catalogueOf, read, f })
737
738
  const answer = json({ ok: true, message: 'If that address belongs to an account here, a link is on its way. It works once, for 30 minutes.' });
738
739
  if (!owner) return answer;
739
740
  const k = await issueChallenge(env, 'recover-link', owner.player, null, PLAYER_LIMITS.linkMinutes);
740
- await sendMail(env, email, `Sign in to ${await studioName()}`, `Someone (hopefully you) asked to get back into your player account on ${await studioName()}.\n\nOpen this link on the device you want to use, and make a new passkey there:\n${url.origin}/account/?do=recover&k=${k}\n\nIt works once, for 30 minutes. If you did not ask, ignore this email: nothing changes.\n`).catch(() => {});
741
+ await sendMail(env, email, `Sign in to ${await studioName()}`, `Someone (hopefully you) asked to get back into your player account on ${await studioName()}.\n\nOpen this link on the device you want to use, and make a new passkey there:\n${url.origin}/account/?do=recover&k=${k}\n\nIt works once, for 30 minutes. If you did not ask, ignore this email: nothing changes.\n`).catch(error => console.warn("Recovery mail delivery failed:",error.message));
741
742
  return answer;
742
743
  }
743
744
 
@@ -1,7 +1,7 @@
1
1
  import { definitiveRefusal, blockingJobsSQL } from './payment-policy.mjs';
2
2
  import { orderFact, offerVersion, orderState } from './purchase-core.mjs';
3
3
  import { problem } from './purchase-problem.mjs';
4
- import { saveRecording, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
4
+ import { saveRecording, recordProviderId, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
5
5
  import { machineCapabilities, notePaymentRefusal } from './payment-capabilities.mjs';
6
6
  import { purchaseResponse } from './purchase-response.mjs';
7
7
  /** One durable purchase, regardless of transport. No seller browser page is involved. */
@@ -262,12 +262,14 @@ function recordingClient(env, o, providerClient) {
262
262
  client.paymentIntents = Object.create(providerClient.paymentIntents);
263
263
  client.paymentIntents.create = async (params, options) => {
264
264
  params = { ...params, metadata: { ...params.metadata, homie: "purchase-v1", order: o.id } };
265
- const id = await saveRecording(env, o, params, options);
265
+ const id = await saveRecording(env, o, params, { ...options, version: options?.apiVersion ?? providerClient.getApiField?.('version') });
266
266
  try {
267
267
  const saved = await env.DB.prepare('SELECT state,payload FROM purchase_jobs WHERE id = ?1').bind(id).first();
268
268
  if (saved.state === 'manual_review') throw new Error('Stripe recording was refused; the seller must handle any refund manually.');
269
269
  const original = JSON.parse(saved.payload);
270
- let pi = await create(original.params, original.options);
270
+ const {version,...originalOptions}=original.options;
271
+ let pi = await create(original.params, { ...originalOptions, apiVersion: version });
272
+ await recordProviderId(env,o,id,pi);
271
273
  if (pi.status === 'processing') pi = await providerClient.paymentIntents.retrieve(pi.id);
272
274
  if (pi.status === 'requires_action') pi = await providerClient.paymentIntents.cancel(pi.id);
273
275
  await recordProviderResult(env, o, id, pi);
@@ -0,0 +1,22 @@
1
+ /** Private per-delivery fence, never a deploy binding. */
2
+ export const RECONCILE_GUARD=Symbol('reconcileGuard');
3
+
4
+ /** Statements may be prepared before an await, so check at execution, including batches. */
5
+ export function guardedDatabase(db,check) {
6
+ const originals=new WeakMap();
7
+ const statement=raw=>{
8
+ const wrapped=new Proxy(raw,{get(target,key){
9
+ if(key==='bind')return (...args)=>statement(target.bind(...args));
10
+ if(['run','first','all','raw'].includes(key))return async(...args)=>{await check();return target[key](...args);};
11
+ return Reflect.get(target,key);
12
+ }});
13
+ originals.set(wrapped,raw);return wrapped;
14
+ };
15
+ return new Proxy(db,{get(target,key){
16
+ if(key==='prepare')return query=>statement(target.prepare(query));
17
+ if(key==='batch')return async statements=>{await check();return target.batch(statements.map(s=>originals.get(s)??s));};
18
+ if(key==='exec')return async(...args)=>{await check();return target.exec(...args);};
19
+ if(key==='withSession')return (...args)=>guardedDatabase(target.withSession(...args),check);
20
+ const value=Reflect.get(target,key);return typeof value==='function'?value.bind(target):value;
21
+ }});
22
+ }
@@ -1,35 +1,60 @@
1
+ export const METADATA_REVISION=Symbol('metadata revision');
1
2
  export const ORIGINAL_DB=Symbol('original database');
2
3
  export const MUTATIONS=Symbol('request mutations');
4
+ export const MUTATION_VERSION=Symbol('mutation version');
3
5
  export const databaseBinding=db=>db?.[ORIGINAL_DB]??db;
4
6
  const metadata=new WeakMap();
5
7
  const metadataQueries=new Set(['SELECT game, launch, max_players, updated_at FROM office_games LIMIT 500','SELECT * FROM servers LIMIT 2000','SELECT game, server, rules, updated_at FROM chat_rules LIMIT 1000']);
6
8
  /** Cache only completed metadata, stamped by a trigger-maintained revision. Pending
7
- * D1 operations belong to one request; unrelated metadata tables are never loaded.
9
+ * D1 operations belong to one request. Cold metadata is one atomic batch.
8
10
  */
9
11
  export function requestDatabase(DB, request, state={mutated:false}, binding=databaseBinding(DB)) {
10
12
  if(!DB)return DB;
11
- let revision,records;
13
+ // Start at the primary so launch/role revocations are current. Later reads in
14
+ // this session may use replicas at or beyond that bookmark. This also meets
15
+ // any bookmark returned by an earlier request without trusting stale auth.
16
+ if(DB===binding&&typeof DB.withSession==='function')DB=DB.withSession('first-primary');
17
+ let revision,records,rows,liveRooms;
18
+ const prefetchRooms=false;
12
19
  const url=new URL(request.url),match=request.method==='GET'&&/^\/([a-z0-9][a-z0-9-]{0,39})\/api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(url.pathname);
13
20
  const recordQuery=match&&(match[3]?'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3':'SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id');
14
21
  const stamp=()=>revision??=(async()=>{
15
22
  const query=DB.prepare("SELECT value FROM meta WHERE key='metadata-revision'");
16
- if(recordQuery){const result=await DB.batch([query,DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined))]);records=result[1];return result[0].results?.[0]?.value??null;}
17
- return (await query.first())?.value??null;
23
+ const cached=metadata.get(binding);
24
+ // The revision and its rows must be from the same snapshot, including when
25
+ // another request changes metadata while this request is awaiting D1.
26
+ const queries=[...metadataQueries];
27
+ const cold=!cached;
28
+ const statements=[query,...(cold?queries.map(sql=>DB.prepare(sql)):[])];
29
+ if(recordQuery)statements.push(DB.prepare(recordQuery).bind(...match.slice(1).filter(x=>x!==undefined)));
30
+ if(prefetchRooms)statements.push(DB.prepare('SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'));
31
+ const result=await DB.batch(statements);
32
+ if(prefetchRooms)liveRooms=result.at(-1);
33
+ let version=result[0].results?.[0]?.value??null;
34
+ if(recordQuery)records=result.at(-1);
35
+ if(cold)rows=new Map(queries.map((sql,i)=>[sql,result[i+1]]));
36
+ else if(cached.version===version)rows=cached.rows;
37
+ else {
38
+ const refreshed=await DB.batch([query,...queries.map(sql=>DB.prepare(sql))]);
39
+ version=refreshed[0].results?.[0]?.value??null;
40
+ rows=new Map(queries.map((sql,i)=>[sql,refreshed[i+1]]));
41
+ }
42
+ if(version!==null)metadata.set(binding,{version,rows});
43
+ return version;
18
44
  })();
19
- const writing=sql=>{if(!/^\s*(SELECT|PRAGMA|EXPLAIN)\b/i.test(sql)){if(/\bapp_records\b/.test(sql)){records=null;revision=null;}if(/\b(office_games|servers|chat_rules)\b/.test(sql)){revision=null;metadata.delete(binding);}if(!/\bstats_daily\b/.test(sql))state.mutated=true;}};
45
+ const writing=sql=>{if(!/^\s*(SELECT|PRAGMA|EXPLAIN)\b/i.test(sql)){if(/\bapp_records\b/.test(sql)){records=null;revision=null;}if(/\b(office_games|servers|chat_rules)\b/.test(sql)){revision=null;metadata.delete(binding);}if(!/\bstats_daily\b/.test(sql)){state.mutated=true;state.version=(state.version??0)+1;}}};
20
46
  const prepare=(sql,args=[])=>{
21
47
  const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
22
48
  const read=async()=>{
49
+ if(sql==='SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'&&prefetchRooms){await stamp();return liveRooms;}
23
50
  if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
24
51
  if(!metadataQueries.has(sql))return original().all();
25
- const version=await stamp();let cache=metadata.get(binding);
26
- if(!cache||cache.version!==version){cache={version,rows:new Map()};metadata.set(binding,cache);}
27
- if(version!==null&&cache.rows.has(sql))return cache.rows.get(sql);
28
- const result=await original().all();if(version!==null)cache.rows.set(sql,result);return result;
52
+ await stamp();
53
+ return rows.get(sql);
29
54
  };
30
55
  return {bind:(...args)=>prepare(sql,args),first:async column=>{writing(sql);if(!metadataQueries.has(sql)&&sql!==recordQuery)return original().first(column);const row=(await read()).results?.[0]??null;return column&&row?row[column]:row;},all:()=>{writing(sql);return read();},run:()=>{writing(sql);return original().run();},raw:(...args)=>{writing(sql);return original().raw(...args);},_original:original,_sql:sql};
31
56
  };
32
- const methods={[ORIGINAL_DB]:binding,get [MUTATIONS](){return state.mutated;},prepare,batch:statements=>{for(const s of statements)writing(s._sql??'UPDATE');return DB.batch(statements.map(s=>s._original?.()??s));},exec:(sql,...args)=>{writing(sql);return DB.exec(sql,...args);},withSession:(...args)=>requestDatabase(DB.withSession(...args),request,state,binding)};
57
+ const methods={[METADATA_REVISION]:stamp,[ORIGINAL_DB]:binding,get [MUTATIONS](){return state.mutated;},get [MUTATION_VERSION](){return state.version??0;},prepare,batch:statements=>{for(const s of statements)writing(s._sql??'UPDATE');return DB.batch(statements.map(s=>s._original?.()??s));},exec:(sql,...args)=>{writing(sql);return binding.exec(sql,...args);},withSession:(...args)=>requestDatabase(binding.withSession(...args),request,state,binding)};
33
58
  return new Proxy(methods,{get(target,key){if(key in target)return target[key];const value=DB[key];return typeof value==='function'?value.bind(DB):value;}});
34
59
  }
35
60
 
@@ -0,0 +1,18 @@
1
+ import {GAME_FILES,PART_FILES} from './asset-paths.mjs';
2
+ export const json = (body, status = 200, extra = {}) => new Response(`${JSON.stringify(body)}\n`, {
3
+ status, headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store', ...extra },
4
+ });
5
+ export function finish(res, path) {
6
+ if (!res || res.status === 101 || res.webSocket) return res;
7
+ if (!/text\/html/i.test(res.headers.get('content-type') ?? '')) return res;
8
+ const cache = res.headers.get('cache-control') ?? '';
9
+ const addNt = !/no-transform/i.test(cache);
10
+ // GAME PARTS: a shared part's preview page is framed by the part's page and the hub, sandboxed (worker/parts.mjs).
11
+ const playerFrame = /^\/[a-z0-9][a-z0-9-]{0,39}\/play\/embed$/.test('/' + path.split('/').filter(Boolean).join('/')) && res.status === 200 && /frame-ancestors/.test(res.headers.get('content-security-policy') ?? '');
12
+ const addXfo = !playerFrame && !res.headers.has('x-frame-options') && !GAME_FILES.test(path) && !PART_FILES.test(path);
13
+ if (!addNt && !addXfo) return res;
14
+ const headers = new Headers(res.headers);
15
+ if (addNt) headers.set('cache-control', cache ? `${cache}, no-transform` : 'no-transform');
16
+ if (addXfo) headers.set('x-frame-options', 'DENY');
17
+ return new Response(res.body, { status: res.status, statusText: res.statusText, headers });
18
+ }