@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/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
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
export const METADATA_REVISION=Symbol('metadata revision');
|
|
2
|
+
export const ORIGINAL_DB=Symbol('original database');
|
|
3
|
+
export const MUTATIONS=Symbol('request mutations');
|
|
4
|
+
export const MUTATION_VERSION=Symbol('mutation version');
|
|
5
|
+
export const databaseBinding=db=>db?.[ORIGINAL_DB]??db;
|
|
6
|
+
const metadata=new WeakMap();
|
|
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']);
|
|
8
|
+
/** Cache only completed metadata, stamped by a trigger-maintained revision. Pending
|
|
9
|
+
* D1 operations belong to one request. Cold metadata is one atomic batch.
|
|
10
|
+
*/
|
|
11
|
+
export function requestDatabase(DB, request, state={mutated:false}, binding=databaseBinding(DB)) {
|
|
12
|
+
if(!DB)return DB;
|
|
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;
|
|
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);
|
|
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');
|
|
21
|
+
const stamp=()=>revision??=(async()=>{
|
|
22
|
+
const query=DB.prepare("SELECT value FROM meta WHERE key='metadata-revision'");
|
|
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;
|
|
44
|
+
})();
|
|
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;}}};
|
|
46
|
+
const prepare=(sql,args=[])=>{
|
|
47
|
+
const original=()=>{const stmt=DB.prepare(sql);return args.length?stmt.bind(...args):stmt;};
|
|
48
|
+
const read=async()=>{
|
|
49
|
+
if(sql==='SELECT game,rooms,updated FROM lobby_summaries LIMIT 500'&&prefetchRooms){await stamp();return liveRooms;}
|
|
50
|
+
if(sql===recordQuery&&JSON.stringify(args)===JSON.stringify(match.slice(1).filter(x=>x!==undefined))){await stamp();return records;}
|
|
51
|
+
if(!metadataQueries.has(sql))return original().all();
|
|
52
|
+
await stamp();
|
|
53
|
+
return rows.get(sql);
|
|
54
|
+
};
|
|
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};
|
|
56
|
+
};
|
|
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)};
|
|
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;}});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A background task may register more work while it settles. */
|
|
62
|
+
export async function drainPending(pending) {
|
|
63
|
+
for(let done=0;done<pending.length;){const end=pending.length;await Promise.allSettled(pending.slice(done,end));done=end;}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Keep the runtime context intact and also deliver writes registered after the
|
|
67
|
+
* response's original drain finished (for example from response-stream work). */
|
|
68
|
+
export function trackBackground(ctx,deliver) {
|
|
69
|
+
const pending=[];let finished=false;
|
|
70
|
+
const context=ctx?.waitUntil?new Proxy(ctx,{get(target,key){
|
|
71
|
+
if(key==='waitUntil')return work=>{
|
|
72
|
+
const task=Promise.resolve(work);pending.push(task);ctx.waitUntil(task);
|
|
73
|
+
if(finished)ctx.waitUntil(Promise.allSettled([task]).then(deliver));
|
|
74
|
+
};
|
|
75
|
+
const value=Reflect.get(target,key,target);return typeof value==='function'?value.bind(target):value;
|
|
76
|
+
}}):ctx;
|
|
77
|
+
return {context,async finish(){await drainPending(pending);finished=true;await deliver();}};
|
|
78
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
/** Chat authorization, rate limits and history for a room. The relay owns its shared state. */
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
import { CHAT_LIMITS, CHAT_RATES, HELD_WORDS, allows, cleanText, floor, normalizeChat, windowMsOf } from './chat.mjs';
|
|
8
|
+
import { CONTROL_LIMITS, oneLine, handleFor, randomId } from './room-protocol.mjs';
|
|
9
|
+
export class RoomChat {
|
|
10
|
+
constructor(room) {this.room=room;}
|
|
11
|
+
chatRules() {
|
|
12
|
+
if (this.room.policy.chat) return this.room.policy.chat;
|
|
13
|
+
const sig = `${this.room.policy.kids}|${this.room.policy.speech}`;
|
|
14
|
+
if (!this.room.defaultChat || this.room.defaultChat.sig !== sig) this.room.defaultChat = { sig, rules: normalizeChat(null, { kids: this.room.policy.kids, speech: this.room.policy.speech }) };
|
|
15
|
+
return this.room.defaultChat.rules;
|
|
16
|
+
}
|
|
17
|
+
recentChat(now = this.room.now()) {
|
|
18
|
+
const keep = windowMsOf(this.room.chatRules());
|
|
19
|
+
return this.room.chatLog.filter((r) => now - r.at <= keep).slice(-CHAT_LIMITS.keep);
|
|
20
|
+
}
|
|
21
|
+
hydrate(recs) {
|
|
22
|
+
const have = new Set(this.room.chatLog.map((r) => r.id));
|
|
23
|
+
const old = (Array.isArray(recs) ? recs : []).filter((r) => r && typeof r.id === 'string' && !have.has(r.id));
|
|
24
|
+
this.room.chatLog = [...old, ...this.room.chatLog].sort((a, b) => a.at - b.at).slice(-CHAT_LIMITS.keep);
|
|
25
|
+
this.room.hydrated = true;
|
|
26
|
+
return old.length;
|
|
27
|
+
}
|
|
28
|
+
wireLine(r) {
|
|
29
|
+
const base = { id: r.id, at: r.at, name: r.name, seat: r.seat, colour: r.colour, by: r.by, ...(r.bubble ? { bubble: true } : {}), ...(r.acct ? { acct: true } : {}), ...(r.owner ? { owner: true } : {}), ...(r.mod ? { mod: true } : {}), ...(r.kept ? { kept: true } : {}) };
|
|
30
|
+
if (r.kind === 'react') return { t: 'react', ...base, react: r.react, glyph: r.glyph };
|
|
31
|
+
// A show-what-you-made card (0.29.0): a page that knows cards draws one; any other shows its words.
|
|
32
|
+
return { t: 'line', ...base, text: r.text, ...(r.say ? { say: r.say } : {}), ...(r.kind === 'card' && r.card ? { card: r.card } : {}) };
|
|
33
|
+
}
|
|
34
|
+
watchIdOf(conn) {
|
|
35
|
+
let id = this.room.watchIds.get(conn);
|
|
36
|
+
if (!id) { id = randomId(6); this.room.watchIds.set(conn, id); }
|
|
37
|
+
return id;
|
|
38
|
+
}
|
|
39
|
+
chatter(client, conn) {
|
|
40
|
+
let g = client;
|
|
41
|
+
if (!g && conn) {
|
|
42
|
+
const mine = this.room.live().filter((o) => !o.agent && ((conn.browser && o.browser === conn.browser) || (conn.player && o.player === conn.player)));
|
|
43
|
+
g = mine.find((o) => o.seat !== null) ?? mine.find((o) => o.watch) ?? mine[0] ?? null;
|
|
44
|
+
}
|
|
45
|
+
const via = String(g?.via ?? conn?.via ?? '');
|
|
46
|
+
const owner = via.split('~').includes('o');
|
|
47
|
+
const seat = g ? g.seat : null;
|
|
48
|
+
const handle = handleFor(g?.token || g?.browser || conn?.browser || `w-${this.room.watchIdOf(conn ?? g?.conn ?? {})}`);
|
|
49
|
+
// A watcher's name: its game's, else the account name the Worker vouched for (the Lounge), else a handle.
|
|
50
|
+
const name = seat !== null ? g.name : oneLine(g?.typed, CHAT_LIMITS.name) || oneLine(conn?.name, CHAT_LIMITS.name) || handle;
|
|
51
|
+
return {
|
|
52
|
+
key: g ? `c:${g.id}` : `w:${this.room.watchIdOf(conn)}`, client: g, seat, name, colour: seat !== null ? g.colour : null,
|
|
53
|
+
player: g?.player ?? conn?.player ?? null, acct: Boolean(g?.acct || conn?.acct), member: Boolean(g?.member || conn?.member) || owner,
|
|
54
|
+
// A moderator the owner named (the Lounge): only the Worker's word, shown on their lines.
|
|
55
|
+
mod: Boolean(conn?.mod) && !owner,
|
|
56
|
+
owner, browser: g?.browser ?? conn?.browser ?? null, token: g?.token ?? null, ip: g?.ip ?? conn?.ip ?? null,
|
|
57
|
+
watch: seat === null, hub: Boolean(conn?.hub) && !g, agent: Boolean(g?.agent), conn: conn ?? g?.conn ?? null,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
takeToken(map, key, rate, now) {
|
|
61
|
+
let b = map.get(key);
|
|
62
|
+
if (!b) { b = { tokens: rate.burst, at: now }; map.set(key, b); }
|
|
63
|
+
b.tokens = Math.min(rate.burst, b.tokens + (now - b.at) / rate.refillMs);
|
|
64
|
+
b.at = now;
|
|
65
|
+
if (b.tokens < 1) return false;
|
|
66
|
+
b.tokens -= 1;
|
|
67
|
+
return true;
|
|
68
|
+
}
|
|
69
|
+
onChat({ client = null, conn = null }, m) {
|
|
70
|
+
const now = this.room.now();
|
|
71
|
+
const rules = this.room.chatRules();
|
|
72
|
+
const who = this.room.chatter(client, conn);
|
|
73
|
+
const n = typeof m.n === 'string' && /^[A-Za-z0-9_-]{1,16}$/.test(m.n) ? m.n : null;
|
|
74
|
+
const kind = m.t === 'react' ? 'react' : typeof m.say === 'string' || typeof m.line === 'string' ? 'line' : 'text';
|
|
75
|
+
const stats = this.room.stats;
|
|
76
|
+
const reply = (why, extra = {}) => {
|
|
77
|
+
stats.chatHeld = stats.chatHeld ?? {};
|
|
78
|
+
stats.chatHeld[why] = (stats.chatHeld[why] ?? 0) + 1;
|
|
79
|
+
const out = JSON.stringify({ t: why === 'slow' ? 'slow' : 'held', why, message: HELD_WORDS[why] ?? HELD_WORDS.unknown, ...(n ? { n } : {}), ...extra });
|
|
80
|
+
if (who.conn) this.room.sendText(who.conn, out);
|
|
81
|
+
return { ok: false, why };
|
|
82
|
+
};
|
|
83
|
+
if (who.agent) return reply('ai_seat');
|
|
84
|
+
if (!allows(rules, kind)) return reply(rules.mode === 'off' ? 'off' : rules.mode === 'emoji' ? 'emoji' : 'lines');
|
|
85
|
+
if (who.hub && !rules.hub) return reply('hub');
|
|
86
|
+
if (who.watch && !rules.watchers && !who.owner) return reply('watchers');
|
|
87
|
+
const need = kind === 'text' ? rules.who : rules.react;
|
|
88
|
+
if (!who.owner && need === 'signed-in' && !who.acct) return reply(kind === 'text' ? 'sign-in' : 'sign-in-react');
|
|
89
|
+
if (!who.owner && need === 'members' && !who.member) return reply(kind === 'text' ? 'members' : 'members-react');
|
|
90
|
+
const mute = this.room.muteOf({ token: who.token, browser: who.browser, player: who.player });
|
|
91
|
+
if (mute) return reply('muted', { until: mute.until });
|
|
92
|
+
const b = this.room.chatBuckets.get(who.key) ?? { lastSayAt: -Infinity, lastText: '', lastTextAt: -Infinity };
|
|
93
|
+
this.room.chatBuckets.set(who.key, b);
|
|
94
|
+
if (!this.room.takeToken(this.room.chatBuckets, `${who.key}|${kind === 'react' ? 'react' : 'say'}`, kind === 'react' ? CHAT_RATES.react : CHAT_RATES.say, now)) return reply('slow');
|
|
95
|
+
if (who.ip && !this.room.takeToken(this.room.chatAddress, who.ip, CHAT_RATES.address, now)) return reply('slow');
|
|
96
|
+
if (kind !== 'react' && rules.slow && !who.owner && now - b.lastSayAt < rules.slow * 1000) return reply('slow', { until: b.lastSayAt + rules.slow * 1000, slow: rules.slow });
|
|
97
|
+
let rec;
|
|
98
|
+
if (kind === 'react') {
|
|
99
|
+
const k = String(m.kind ?? m.react ?? '');
|
|
100
|
+
const r = rules.emoji.find((x) => x.k === k);
|
|
101
|
+
if (!r) return reply('unknown');
|
|
102
|
+
// A storm of reactions: the room fans out at most so many a second (the sender's own still floats for them).
|
|
103
|
+
while (this.room.chatReacts.length && this.room.chatReacts[0] <= now - 1000) this.room.chatReacts.shift();
|
|
104
|
+
if (this.room.chatReacts.length >= CHAT_RATES.roomReactsPerSecond) { stats.chatDrops = (stats.chatDrops ?? 0) + 1; return { ok: true, dropped: true }; }
|
|
105
|
+
this.room.chatReacts.push(now);
|
|
106
|
+
rec = { kind: 'react', react: r.k, glyph: r.e };
|
|
107
|
+
} else if (kind === 'line') {
|
|
108
|
+
const id = String(m.say ?? m.line ?? '');
|
|
109
|
+
const l = rules.lines.find((x) => x.id === id);
|
|
110
|
+
if (!l) return reply('unknown');
|
|
111
|
+
rec = { kind: 'line', say: l.id, text: l.text };
|
|
112
|
+
} else {
|
|
113
|
+
const text = cleanText(m.text, rules.max);
|
|
114
|
+
if (!text) return reply('empty');
|
|
115
|
+
const f = floor(text, rules);
|
|
116
|
+
if (!f.ok) return reply(f.why);
|
|
117
|
+
if (b.lastText === text.toLowerCase() && now - b.lastTextAt < CHAT_LIMITS.repeatMs) return reply('repeat');
|
|
118
|
+
b.lastText = text.toLowerCase();
|
|
119
|
+
b.lastTextAt = now;
|
|
120
|
+
rec = { kind: 'text', text };
|
|
121
|
+
}
|
|
122
|
+
if (kind !== 'react') b.lastSayAt = now;
|
|
123
|
+
Object.assign(rec, {
|
|
124
|
+
name: who.name, seat: who.seat, colour: who.colour, by: who.hub ? 'hub' : who.watch ? 'watcher' : 'player',
|
|
125
|
+
bubble: m.bubble !== false && rules.bubbles && who.seat !== null, acct: who.acct, owner: who.owner, ...(who.mod ? { mod: true } : {}),
|
|
126
|
+
// Only the office sees these: who sent it, so a mute or a kick from the line reaches them (never an address).
|
|
127
|
+
from: { client: who.client?.id ?? null, token: who.token, browser: who.browser, player: who.player },
|
|
128
|
+
});
|
|
129
|
+
if (kind === 'text' && rules.ai && typeof this.room.review === 'function' && !who.owner) {
|
|
130
|
+
if (this.room.chatPending >= CHAT_LIMITS.pending) return reply('busy');
|
|
131
|
+
this.room.chatPending += 1;
|
|
132
|
+
const t0 = now;
|
|
133
|
+
Promise.resolve()
|
|
134
|
+
.then(() => this.room.review(rec.text, { room: this.room.code, links: rules.links }))
|
|
135
|
+
.then((v) => v, (error) => ({ ok: true, by: 'error', error: String(error?.message ?? error).slice(0, 120) }))
|
|
136
|
+
.then((v) => {
|
|
137
|
+
this.room.chatPending = Math.max(0, this.room.chatPending - 1);
|
|
138
|
+
if (v && v.ok === false) {
|
|
139
|
+
stats.chatAiHeld = (stats.chatAiHeld ?? 0) + 1;
|
|
140
|
+
// What the review thought it was (insult, hate, sexual, grooming, harm, spam): counts for the office only.
|
|
141
|
+
stats.chatAiWhy = stats.chatAiWhy ?? {};
|
|
142
|
+
const why = String(v.why ?? 'held').slice(0, 24);
|
|
143
|
+
stats.chatAiWhy[why] = (stats.chatAiWhy[why] ?? 0) + 1;
|
|
144
|
+
reply('ai');
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (v?.by === 'error') stats.chatAiErrors = (stats.chatAiErrors ?? 0) + 1;
|
|
148
|
+
rec.review = { by: v?.by ?? 'ai', ms: this.room.now() - t0, ...(Number.isFinite(v?.p) ? { p: v.p } : {}) };
|
|
149
|
+
// A mute that came while it was being reviewed still holds it.
|
|
150
|
+
if (this.room.muteOf({ token: who.token, browser: who.browser, player: who.player })) { reply('muted'); return; }
|
|
151
|
+
this.room.publishChat(rec, who.conn, n);
|
|
152
|
+
});
|
|
153
|
+
return { ok: true, pending: true };
|
|
154
|
+
}
|
|
155
|
+
return this.room.publishChat(rec, who.conn, n);
|
|
156
|
+
}
|
|
157
|
+
publishChat(rec, senderConn = null, n = null) {
|
|
158
|
+
const now = this.room.now();
|
|
159
|
+
rec.id = randomId(6);
|
|
160
|
+
rec.at = now;
|
|
161
|
+
this.room.chatLog.push(rec);
|
|
162
|
+
const keep = windowMsOf(this.room.chatRules());
|
|
163
|
+
while (this.room.chatLog.length > CHAT_LIMITS.keep || (this.room.chatLog.length && now - this.room.chatLog[0].at > keep)) this.room.chatLog.shift();
|
|
164
|
+
const msg = this.room.wireLine(rec);
|
|
165
|
+
const text = JSON.stringify(msg);
|
|
166
|
+
const mine = n ? JSON.stringify({ ...msg, n }) : text;
|
|
167
|
+
const hub = this.room.chatRules().hub;
|
|
168
|
+
for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, o.conn === senderConn ? mine : text);
|
|
169
|
+
for (const w of this.room.watchers) if (!w.hub || hub) this.room.sendText(w, w === senderConn ? mine : text);
|
|
170
|
+
if (rec.kind === 'react') this.room.stats.chatReacts = (this.room.stats.chatReacts ?? 0) + 1;
|
|
171
|
+
else this.room.stats.chatLines = (this.room.stats.chatLines ?? 0) + 1;
|
|
172
|
+
// Kept chat (0.29.0): a room whose rules keep history hands what was said to the Table (never a reaction).
|
|
173
|
+
if (rec.kind !== 'react' && this.room.chatRules().history > 0 && typeof this.room.onPublished === 'function') { try { this.room.onPublished(rec); } catch { /* kept next time */ } }
|
|
174
|
+
return { ok: true, id: rec.id };
|
|
175
|
+
}
|
|
176
|
+
unsay(ids) {
|
|
177
|
+
const gone = new Set(ids);
|
|
178
|
+
const before = this.room.chatLog.length;
|
|
179
|
+
this.room.chatLog = this.room.chatLog.filter((r) => !gone.has(r.id));
|
|
180
|
+
const text = JSON.stringify({ t: 'unline', ids: [...gone] });
|
|
181
|
+
for (const o of this.room.live()) if (!this.room.lite(o)) this.room.sendText(o.conn, text);
|
|
182
|
+
for (const w of this.room.watchers) this.room.sendText(w, text);
|
|
183
|
+
// Kept chat: what is taken down here is deleted where it was kept, older lines than the window too.
|
|
184
|
+
if (typeof this.room.onUnsaid === 'function' && gone.size) { try { this.room.onUnsaid([...gone]); } catch { /* best effort */ } }
|
|
185
|
+
return before - this.room.chatLog.length;
|
|
186
|
+
}
|
|
187
|
+
ownLine(r, conn) {
|
|
188
|
+
const f = r?.from ?? {};
|
|
189
|
+
if (!conn || r?.kind === 'studio') return false;
|
|
190
|
+
return Boolean((conn.player && f.player && f.player === conn.player) || (conn.browser && f.browser && f.browser === conn.browser && !f.player));
|
|
191
|
+
}
|
|
192
|
+
unsayOwn(conn, id) {
|
|
193
|
+
if (typeof id !== 'string' || !/^[A-Za-z0-9_-]{1,16}$/.test(id)) return { ok: false, error: 'no-line' };
|
|
194
|
+
const r = this.room.chatLog.find((x) => x.id === id);
|
|
195
|
+
if (!r || r.kind === 'studio') return { ok: false, error: 'no-line' };
|
|
196
|
+
if (!this.room.ownLine(r, conn)) return { ok: false, error: 'not-yours' };
|
|
197
|
+
this.room.unsay([id]);
|
|
198
|
+
this.room.stats.chatOwnRemoved = (this.room.stats.chatOwnRemoved ?? 0) + 1;
|
|
199
|
+
return { ok: true, removed: 1 };
|
|
200
|
+
}
|
|
201
|
+
holdSender(line, minutes, now, { kick = false, message = '', purge = false } = {}) {
|
|
202
|
+
const f = line.from ?? {};
|
|
203
|
+
if (!f.token && !f.browser && !f.player) return { ok: false, error: 'no-player', message: 'that line has nobody to hold' };
|
|
204
|
+
const until = now + minutes * 60_000;
|
|
205
|
+
const hold = { token: f.token ?? null, browser: f.browser ?? null, player: f.player ?? null, name: line.name, seat: line.seat, until, at: now };
|
|
206
|
+
if (kick) this.room.bans = [...this.room.bans, { ...hold, ip: null, pass: null, message }].slice(-CONTROL_LIMITS.bans);
|
|
207
|
+
else this.room.mutes = [...this.room.mutes, hold].slice(-CONTROL_LIMITS.mutes);
|
|
208
|
+
this.room.tellBrowser(hold, kick ? { t: 'kicked', room: this.room.code, until, message } : { t: 'muted', room: this.room.code, until });
|
|
209
|
+
const removed = purge ? this.room.unsay(this.room.linesOf(f)) : 0;
|
|
210
|
+
this.room.tellWatchers();
|
|
211
|
+
return { ok: true, op: kick ? 'kick' : 'mute', name: line.name, seat: line.seat, until, sockets: 0, ...(removed ? { removed } : {}) };
|
|
212
|
+
}
|
|
213
|
+
linesOf(from) {
|
|
214
|
+
if (!from) return [];
|
|
215
|
+
return this.room.chatLog.filter((r) => (from.client && r.from?.client === from.client) || (from.token && r.from?.token === from.token) || (from.browser && r.from?.browser === from.browser) || (from.player && r.from?.player === from.player)).map((r) => r.id);
|
|
216
|
+
}}
|