@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.
- package/CHANGELOG.md +27 -0
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +4 -1
- package/lib/cloudflare.mjs +16 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +28 -10
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +42 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-streams.mjs +3 -3
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +23 -11
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +54 -2785
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +16 -20
- package/worker/operational-schema.mjs +14 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +8 -28
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +35 -10
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop.mjs +27 -20
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- 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
|
-
|
|
23
|
-
// Never what names a build (the rules' build and state hashes, the bundle's version): those change with every
|
|
24
|
-
// edit, and a newer build of the same game inside the same page is exactly what the frame's own reload is for.
|
|
25
|
-
const { build: _build, stateHash: _state, ...room } = g?.room ?? {};
|
|
26
|
-
const { version: _version, ...netplay } = g?.netplay ?? {};
|
|
27
|
-
const text = JSON.stringify([g?.kind ?? null, g?.players ?? null, room, g?.saves ?? null, g?.agents ?? null, g?.screen ?? null, netplay, g?.launch ?? null]);
|
|
28
|
-
let h = 2166136261;
|
|
29
|
-
for (let i = 0; i < text.length; i += 1) { h ^= text.charCodeAt(i); h = Math.imul(h, 16777619); }
|
|
30
|
-
return `${STUDIO_VERSION_TAG}-${(h >>> 0).toString(36)}`;
|
|
31
|
-
}
|
|
24
|
+
|
|
32
25
|
/**
|
|
33
26
|
* The frame's stand-in when its page is older than the game it would host. It tells the page, and loads no game.
|
|
34
27
|
* `key`: the page understands the handshake and reloads itself once. No key: a page from before the handshake, whose
|
|
35
28
|
* only way to load a whole new page is the one it has for leaving a finished room; it is used only when the game cannot
|
|
36
29
|
* work inside that page at all.
|
|
37
30
|
*/
|
|
38
|
-
|
|
39
|
-
const message = key ? { t: 'homie-net', what: 'shell', key } : { t: 'homie-net', what: 'rematch', room };
|
|
40
|
-
return `<!doctype html><meta charset="utf-8"><title>Updating</title><body style="margin:0;background:#0b0d14;color:#cfd6e6;font:15px system-ui;display:grid;place-items:center;height:100vh"><p>This game was updated. Loading it again…</p><script>try{parent.postMessage(${JSON.stringify(message).replace(/</g, '\\u003c')},'*');}catch(e){}</script>`;
|
|
41
|
-
}
|
|
31
|
+
|
|
42
32
|
import { SKILLS } from './agents.mjs';
|
|
43
33
|
import { KIDS_LINE, POLICY_WORDS } from './servers.mjs';
|
|
44
34
|
import { SAVES_SHELL_CSS, SAVES_SHELL_JS } from './saves-shell.mjs';
|
|
@@ -49,18 +39,13 @@ import { ARRIVAL_JS, arrivalCard } from './arrival.mjs';
|
|
|
49
39
|
export { homePage, mediaIndexPage, notFoundPage, songPage, videoPage } from './site.mjs';
|
|
50
40
|
|
|
51
41
|
/** A room code: 1 to 32 letters, digits, - or _. */
|
|
52
|
-
|
|
42
|
+
|
|
53
43
|
|
|
54
44
|
/**
|
|
55
45
|
* Who may put the play page (and so the game) inside a frame: this site, plus the https origins studio.json
|
|
56
46
|
* `site.frameAncestors` names (for example a hub that embeds the game). Anything else is refused by the browser.
|
|
57
47
|
*/
|
|
58
|
-
|
|
59
|
-
const extra = (Array.isArray(cat?.studio?.site?.frameAncestors) ? cat.studio.site.frameAncestors : [])
|
|
60
|
-
.map((o) => { try { const u = new URL(String(o)); return u.protocol === 'https:' && u.origin === String(o).replace(/\/+$/, '') ? u.origin : null; } catch { return null; } })
|
|
61
|
-
.filter(Boolean).slice(0, 8);
|
|
62
|
-
return ["'self'", ...extra].join(' ');
|
|
63
|
-
}
|
|
48
|
+
|
|
64
49
|
|
|
65
50
|
/** What a play, big-screen or watch page answers to a room code it cannot use: said on the page, never a silent public room. */
|
|
66
51
|
export function badRoomPage(cat, g, raw, { screen = false, watch = false } = {}) {
|
|
@@ -1596,12 +1581,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1596
1581
|
}
|
|
1597
1582
|
}());`;
|
|
1598
1583
|
|
|
1599
|
-
|
|
1600
|
-
const normal = playerAncestors(cat);
|
|
1601
|
-
if (!preview || !/^http:\/\/(?:localhost|127\.0\.0\.1)(?::\d+)?$/.test(origin)) return normal;
|
|
1602
|
-
const port = new URL(origin).port;
|
|
1603
|
-
return `${normal === "'none'" ? '' : normal} http://localhost${port ? ':' + port : ''} http://127.0.0.1${port ? ':' + port : ''}`.trim();
|
|
1604
|
-
}
|
|
1584
|
+
|
|
1605
1585
|
|
|
1606
1586
|
export function embedPreview(cat, g, origin) {
|
|
1607
1587
|
const frameOrigin = new URL(origin); frameOrigin.hostname = frameOrigin.hostname === 'localhost' ? '127.0.0.1' : 'localhost';
|
package/worker/parts.mjs
CHANGED
|
@@ -17,7 +17,8 @@ import { esc, icon, layout, notFoundPage } from './site.mjs';
|
|
|
17
17
|
const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
|
|
18
18
|
const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
|
|
19
19
|
/** A packed part's files: framed by the part's own page and by the hub, so they keep their own frame rule (index.mjs finish). */
|
|
20
|
-
|
|
20
|
+
import {PART_FILES} from './asset-paths.mjs';
|
|
21
|
+
export {PART_FILES} from './asset-paths.mjs';
|
|
21
22
|
export const PARTS_WELL_KNOWN = '/.well-known/homie-parts.json';
|
|
22
23
|
export const isPartsPath = (path) => path === '/openapi.json' || path === '/.well-known/api-catalog' || path === '/parts/catalog.json' || path === '/purchases/keys.json' || path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
|
|
23
24
|
|
|
@@ -2,13 +2,13 @@ import { pendingJobsSQL, definitiveRefusal, REPLAY_LIMIT_MS, recoveryAction } fr
|
|
|
2
2
|
import { orderFact, recordOrderState, releaseClaim } from './purchase-core.mjs';
|
|
3
3
|
import { resumeSettlement } from './settlement-journal.mjs';
|
|
4
4
|
/** Durable provider recordings outlive responses. A verified chain transfer is payment. */
|
|
5
|
-
import { stripeCall, refundPayment, modeOf } from './stripe.mjs';
|
|
5
|
+
import { stripeCall, refundPayment, modeOf, STRIPE_VERSION } from './stripe.mjs';
|
|
6
6
|
import { byId, resourceAvailable } from './purchase-store.mjs';
|
|
7
7
|
export async function saveRecording(env, order, params, options) {
|
|
8
8
|
const chain = params.payment_method_options?.crypto?.transaction_verification_options;
|
|
9
9
|
const id = chain ? `${order.id}:${chain.network}:${chain.transaction_hash}` : options.idempotencyKey;
|
|
10
10
|
await env.DB.prepare("INSERT OR IGNORE INTO purchase_jobs (id,order_id,mode,state,payload,updated_at) VALUES (?1,?2,?3,?4,?5,?6)")
|
|
11
|
-
.bind(id, order.id, order.mode, chain ? 'paid_recording' : 'submitted', JSON.stringify({ params, options: { ...options, idempotencyKey: chain ? chain.transaction_hash : options.idempotencyKey }, chain, startedAt: Date.now() }), Date.now()).run();
|
|
11
|
+
.bind(id, order.id, order.mode, chain ? 'paid_recording' : 'submitted', JSON.stringify({ params, options: { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION, idempotencyKey: chain ? chain.transaction_hash : options.idempotencyKey }, chain, startedAt: Date.now() }), Date.now()).run();
|
|
12
12
|
if (chain) {
|
|
13
13
|
// Only the protocol's verified settlement callback reaches here, never buyer input.
|
|
14
14
|
await orderFact(env, order.id, 'payment', {paid_at: Date.now()}).run();
|
|
@@ -16,6 +16,11 @@ export async function saveRecording(env, order, params, options) {
|
|
|
16
16
|
}
|
|
17
17
|
return id;
|
|
18
18
|
}
|
|
19
|
+
/** Persist the provider id before any follow-up retrieve/cancel/fulfillment call. */
|
|
20
|
+
export async function recordProviderId(env, order, id, pi) {
|
|
21
|
+
if (!/^pi_[A-Za-z0-9]+$/.test(pi.id) || pi.amount !== order.amount || pi.currency !== order.currency || pi.livemode !== (order.mode === 'live')) throw new Error('Provider receipt does not match order');
|
|
22
|
+
await env.DB.prepare('UPDATE purchase_jobs SET payment=?2,updated_at=?3 WHERE id=?1').bind(id,pi.id,Date.now()).run();
|
|
23
|
+
}
|
|
19
24
|
export async function recordProviderResult(env, order, id, pi) {
|
|
20
25
|
if (pi.amount !== order.amount || pi.currency !== order.currency || pi.livemode !== (order.mode === 'live')) throw new Error('Provider receipt does not match order');
|
|
21
26
|
await env.DB.prepare("UPDATE purchase_jobs SET payment = ?2, state = ?3, error = NULL, updated_at = ?4 WHERE id = ?1").bind(id, pi.id, pi.status === 'succeeded' ? 'recorded' : ['canceled','requires_payment_method'].includes(pi.status) ? 'failed' : 'processing', Date.now()).run();
|
|
@@ -37,31 +42,40 @@ export async function recordingFailure(env, id, error) {
|
|
|
37
42
|
await env.DB.prepare("UPDATE purchase_jobs SET state = 'failed', error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1 AND state = 'submitted'").bind(id, String(error.message).slice(0, 500), Date.now()).run();
|
|
38
43
|
return;
|
|
39
44
|
}
|
|
45
|
+
if(error.manualReview) {
|
|
46
|
+
await env.DB.prepare("UPDATE purchase_jobs SET state='manual_review',error=?2,updated_at=?3 WHERE id=?1").bind(id,error.message,Date.now()).run();
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
40
49
|
// A failure of our call is not a fact about the original payment.
|
|
41
50
|
await env.DB.prepare("UPDATE purchase_jobs SET error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1")
|
|
42
51
|
.bind(id, String(error.message).slice(0, 500), Date.now()).run();
|
|
43
52
|
}
|
|
44
53
|
|
|
45
|
-
/** Read
|
|
54
|
+
/** Read the recorded object, or recover a lost create response with its original key. */
|
|
46
55
|
export async function readProviderPayment(env, order, job, payment = null) {
|
|
47
|
-
const
|
|
56
|
+
const readOptions = { version: STRIPE_VERSION };
|
|
48
57
|
if (payment && !/^pi_[A-Za-z0-9]+$/.test(payment)) throw new Error('Supply an existing Stripe PaymentIntent id');
|
|
49
58
|
if (job.payment || payment) {
|
|
50
|
-
const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(job.payment ?? payment)}`, null,
|
|
59
|
+
const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(job.payment ?? payment)}`, null, readOptions);
|
|
51
60
|
if (pi.metadata?.order !== order.id) throw new Error('The existing PaymentIntent belongs to another order');
|
|
52
61
|
return pi;
|
|
53
62
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
const
|
|
59
|
-
if
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
+
const {params,options,startedAt}=JSON.parse(job.payload??'{}');
|
|
64
|
+
if(params&&options?.idempotencyKey&&Date.now()-(startedAt??order.created_at)<REPLAY_LIMIT_MS) {
|
|
65
|
+
// If the create response was lost, replay only the original request/key
|
|
66
|
+
// inside Stripe's guaranteed retention window. Never scan the account.
|
|
67
|
+
const version=options.version ?? options.apiVersion;
|
|
68
|
+
if(!version) {
|
|
69
|
+
const error=new Error('Original Stripe API version is missing; reconcile this retained order with an existing PaymentIntent id instead of replaying an uncertain request.');
|
|
70
|
+
error.manualReview=true;throw error;
|
|
71
|
+
}
|
|
72
|
+
const pi=await stripeCall(env,'POST','/v1/payment_intents',params,{...options,version});
|
|
73
|
+
await recordProviderId(env,order,job.id,pi);
|
|
74
|
+
return pi;
|
|
63
75
|
}
|
|
64
|
-
|
|
76
|
+
const error=new Error(`PaymentIntent id missing after the safe replay window for order ${order.id}; reconcile with an existing PaymentIntent id. The order and its claim are retained.`);
|
|
77
|
+
error.manualReview=true;
|
|
78
|
+
throw error;
|
|
65
79
|
}
|
|
66
80
|
async function releaseUnpaid(env, order, job, pi = null) {
|
|
67
81
|
await env.DB.batch([
|
|
@@ -87,18 +101,18 @@ export async function reconcileRecordings(env, orderId = null, dependencies = {}
|
|
|
87
101
|
switch (recoveryAction(observation)) {
|
|
88
102
|
case 'fail':
|
|
89
103
|
if (pi?.status === 'requires_payment_method') {
|
|
90
|
-
pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version:
|
|
104
|
+
pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: STRIPE_VERSION });
|
|
91
105
|
if (pi.status !== 'canceled') throw new Error('The unpaid payment could not be canceled; the claim remains reserved');
|
|
92
106
|
}
|
|
93
107
|
await releaseUnpaid(env, order, job, pi); continue;
|
|
94
|
-
case 'replay': pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version:
|
|
108
|
+
case 'replay': pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION }); break;
|
|
95
109
|
default:
|
|
96
110
|
if (!pi) {
|
|
97
111
|
if (deadline) throw new Error(`Paid transfer still needs recording: order ${order.id}; key ${options.idempotencyKey}`);
|
|
98
|
-
pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version:
|
|
112
|
+
pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: options.version ?? options.apiVersion ?? STRIPE_VERSION });
|
|
99
113
|
}
|
|
100
114
|
}
|
|
101
|
-
if (['requires_action','requires_confirmation','requires_capture','requires_payment_method'].includes(pi.status) || orderId && dependencies.cancel === true && pi.status === 'processing') pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version:
|
|
115
|
+
if (['requires_action','requires_confirmation','requires_capture','requires_payment_method'].includes(pi.status) || orderId && dependencies.cancel === true && pi.status === 'processing') pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: STRIPE_VERSION });
|
|
102
116
|
await recordProviderResult(env, order, job.id, pi);
|
|
103
117
|
if (['canceled','requires_payment_method'].includes(pi.status)) { await releaseUnpaid(env, order, job, pi); continue; }
|
|
104
118
|
if (pi.status !== 'succeeded') {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export let players = null;
|
|
2
|
+
let configured=false;
|
|
3
|
+
/**
|
|
4
|
+
* The player-accounts API, handed over once by a studio's Worker that has it:
|
|
5
|
+
* `{ get(id, env), of?(request, env), isOwner?(request, env) }` (worker/players.mjs `players` is exactly this).
|
|
6
|
+
*/
|
|
7
|
+
export function usePlayers(api) { configured=true; players = api && typeof api === 'object' ? api : null; }
|
|
8
|
+
|
|
9
|
+
export function defaultPlayers(api) { if(!configured)players=api; }
|
package/worker/players.mjs
CHANGED
|
@@ -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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
+
}
|
package/worker/request-db.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
17
|
-
|
|
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
|
-
|
|
26
|
-
|
|
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
|
|
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
|
+
}
|