@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.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- 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/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- 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 +58 -2772
- 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 +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- 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/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- 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/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
|
-
|
|
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
|
-
|
|
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))
|
|
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()
|
|
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
|
-
/**
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
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
|
-
|
|
1389
|
-
|
|
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
|
-
|
|
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 } = {}) {
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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; }
|