@homie-rocks/studio 0.50.6 → 0.50.7
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 +20 -0
- package/apps/APPS.md +45 -1
- package/bin/homie-studio.mjs +3 -3
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +48 -4
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +8 -1
- package/lib/template-history.json +42 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +2 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +16 -9
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +13 -2
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +12 -0
- package/worker/shop-page.mjs +2 -2
- package/worker/shop.mjs +1 -0
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/lib/dev.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {createLocalCredential} from './local-test.mjs';
|
|
1
2
|
import {workerEntry} from './worker-build.mjs';
|
|
2
3
|
import { networkInterfaces } from 'node:os';
|
|
3
4
|
/**
|
|
@@ -132,7 +133,7 @@ export function devState(root) {
|
|
|
132
133
|
export function runningDev(root) {
|
|
133
134
|
const st = devState(root);
|
|
134
135
|
if (st.state !== 'running' || !st.port) return null;
|
|
135
|
-
return { url: `http://
|
|
136
|
+
return { url: `http://localhost:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
|
|
136
137
|
}
|
|
137
138
|
|
|
138
139
|
async function endAll(pids, ms = 8000) {
|
|
@@ -292,15 +293,14 @@ function passLines(stream, to, opts) {
|
|
|
292
293
|
* saved. `rulesStamp` is what it compares: the newest change among them, and how many there are.
|
|
293
294
|
*/
|
|
294
295
|
export function rulesStamp(g) {
|
|
295
|
-
const
|
|
296
|
-
for
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
return `${n}:${Math.round(newest)}`;
|
|
296
|
+
const rows=[];
|
|
297
|
+
const walk=dir=>{for(const entry of readdirSync(dir,{withFileTypes:true})) {
|
|
298
|
+
if(['node_modules','.git','.wrangler','.studio','.port','.cache','.parcel-cache','.vite','.DS_Store','coverage','dist','codex'].includes(entry.name))continue;
|
|
299
|
+
const file=join(dir,entry.name);
|
|
300
|
+
if(entry.isDirectory())walk(file);
|
|
301
|
+
else if(entry.isFile()){const st=statSync(file);rows.push(`${file}:${st.size}:${st.mtimeMs}:${st.ctimeMs}`);}
|
|
302
|
+
}};
|
|
303
|
+
walk(g.dir);return rows.sort().join('|');
|
|
304
304
|
}
|
|
305
305
|
|
|
306
306
|
const builtIds = (root) => { try { return JSON.parse(readFileSync(join(root, 'site', 'dist', 'games.json'), 'utf8')).games.map((g) => g.id); } catch { return null; } };
|
|
@@ -316,8 +316,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
316
316
|
// What is really there, before anything is built or started (the header: stale state heals itself).
|
|
317
317
|
const st = devState(root);
|
|
318
318
|
if (st.state === 'running') {
|
|
319
|
-
const at = st.port ? `http://
|
|
320
|
-
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://
|
|
319
|
+
const at = st.port ? `http://localhost:${st.port}/` : 'an unknown port';
|
|
320
|
+
return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://localhost:${st.port}` } : {}), why: `this studio's dev server is already running at ${at} (process ${st.pids.join(', ')}). Use that one, or stop it first: ${stop}` };
|
|
321
321
|
}
|
|
322
322
|
if (st.state === 'stale') {
|
|
323
323
|
rmSync(st.file, { force: true });
|
|
@@ -329,7 +329,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
329
329
|
if (p === Number(port) && left.open) {
|
|
330
330
|
const busy = port;
|
|
331
331
|
do { port = String(Number(port) + 1); } while (Number(port) < 65535 && await portOpen(Number(port)));
|
|
332
|
-
log(`Port ${busy} is busy; using http://
|
|
332
|
+
log(`Port ${busy} is busy; using http://localhost:${port}/`);
|
|
333
333
|
}
|
|
334
334
|
}
|
|
335
335
|
|
|
@@ -348,7 +348,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
348
348
|
m.on('error', done);
|
|
349
349
|
});
|
|
350
350
|
const origin = `http://127.0.0.1:${port}`;
|
|
351
|
-
|
|
351
|
+
const printedOrigin = `http://localhost:${port}`;
|
|
352
|
+
log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
|
|
352
353
|
if (lan) for (const address of lanAddresses()) log(`Same Wi-Fi: http://${address}:${port}/ (open this address on phones and wall screens)`);
|
|
353
354
|
const ai = devConfig(root, remoteAi);
|
|
354
355
|
if (ai.note) log(ai.note);
|
|
@@ -362,7 +363,8 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
362
363
|
}
|
|
363
364
|
// --remote-ai: Wrangler's --local turns every remote binding off ("not supported"), so a dev with the real Workers AI
|
|
364
365
|
// (the guides' brains, room chat's review) runs without it; everything else stays local all the same.
|
|
365
|
-
const
|
|
366
|
+
const localSecret = createLocalCredential(root);
|
|
367
|
+
const args = ['dev', '--var', `HOMIE_LOCAL_TEST_SECRET:${localSecret}`, ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
|
|
366
368
|
log(`Stop it with: ${stop} (this studio's dev server only)`);
|
|
367
369
|
|
|
368
370
|
let child = null;
|
|
@@ -417,13 +419,13 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
417
419
|
} finally { busy = false; }
|
|
418
420
|
};
|
|
419
421
|
// A rules game saved while dev runs is built again (see rulesStamp), one game at a time.
|
|
420
|
-
const stamps = new Map(listGames(root).
|
|
422
|
+
const stamps = new Map(listGames(root).map((g) => [g.id, rulesStamp(g)]));
|
|
421
423
|
const timingRetries = new Map();
|
|
422
424
|
let rebuilding = false;
|
|
423
425
|
let published = Promise.resolve();
|
|
424
426
|
const rulesLook = async () => {
|
|
425
427
|
if (rebuilding || ending) return;
|
|
426
|
-
for (const g of listGames(root)
|
|
428
|
+
for (const g of listGames(root)) {
|
|
427
429
|
const now = rulesStamp(g);
|
|
428
430
|
const retry = timingRetries.get(g.id);
|
|
429
431
|
if (stamps.get(g.id) === now && !(retry?.stamp === now && Date.now() >= retry.at)) continue;
|
package/lib/events-cli.mjs
CHANGED
|
@@ -31,7 +31,11 @@ export async function eventsWebhook(root,action,name,url,{run,connect=connectToo
|
|
|
31
31
|
writeStudio(root,studio);
|
|
32
32
|
return {ok:true,command:'events webhook '+action,feed:name,...(secret?{secret}:{}),next:action==='rotate'?'Key replaced in the Worker. Configure the receiver with this key; it is shown only here.':'Build, commit and deploy the updated feed declaration through your usual authorized workflow.'};
|
|
33
33
|
}
|
|
34
|
-
export function eventsCommand(root,sub,positional,flags,options={}) {
|
|
34
|
+
export async function eventsCommand(root,sub,positional,flags,options={}) {
|
|
35
|
+
if(sub==='clear'||sub==='read') {
|
|
36
|
+
const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',options);
|
|
37
|
+
try {const result=await client.callTool({name:'studio_events_'+sub,arguments:sub==='clear'?{through:Number(flags.get('through'))}:{feed:positional[2],after:Number(flags.get('after')??0)}});return {ok:!result.isError,result};} finally {await client.close();}
|
|
38
|
+
}
|
|
35
39
|
if(sub!=='webhook')throw new Error('Use events webhook <add|remove|rotate|status|test>');
|
|
36
40
|
return eventsWebhook(root,positional[2],positional[3],positional[4]??flags.get('url'),{...options,after:flags.get('after')??0});
|
|
37
41
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import {readFileSync,mkdirSync,writeFileSync,chmodSync} from 'node:fs';
|
|
2
|
+
import {join} from 'node:path';
|
|
3
|
+
import {randomBytes,createHmac} from 'node:crypto';
|
|
4
|
+
export function createLocalCredential(root) {
|
|
5
|
+
const secret=randomBytes(32).toString('hex');
|
|
6
|
+
mkdirSync(join(root,'.studio'),{recursive:true});
|
|
7
|
+
const file=join(root,'.studio','local-test.json');
|
|
8
|
+
writeFileSync(file,JSON.stringify({secret}),{mode:0o600});chmodSync(file,0o600);
|
|
9
|
+
return secret;
|
|
10
|
+
}
|
|
11
|
+
export function localCredential(root,site) {
|
|
12
|
+
if(!root||!['localhost','127.0.0.1','[::1]'].includes(new URL(site).hostname))return null;
|
|
13
|
+
try{return JSON.parse(readFileSync(join(root,'.studio','local-test.json'),'utf8')).secret;}catch{return null;}
|
|
14
|
+
}
|
|
15
|
+
export function localCookie(secret,session) {
|
|
16
|
+
const data=Buffer.from(JSON.stringify(session)).toString('base64url');
|
|
17
|
+
return data+'.'+createHmac('sha256',secret).update(data).digest('hex');
|
|
18
|
+
}
|
package/lib/office.mjs
CHANGED
|
@@ -29,7 +29,7 @@ const GAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
|
29
29
|
/** Call the studio's office API with a key that lives ten minutes and is dropped as soon as the answer is in. */
|
|
30
30
|
export async function withKey(root, url, fn) {
|
|
31
31
|
const { studio, site } = siteOf(root, url);
|
|
32
|
-
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://
|
|
32
|
+
if (!site) return { ok: false, why: 'this studio has no live site yet: `npm run deploy` first, or give --url http://localhost:8787 for `npm run dev`' };
|
|
33
33
|
const local = loopback(site);
|
|
34
34
|
const k = mint(root, studio, 'office', 10 * 60_000, { local });
|
|
35
35
|
let expiresAt = Date.now() + 10 * 60_000;
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {TOOL_DATABASE_MIGRATION,TOOL_DATABASE_MIGRATION_FILE} from '../worker/tool-database.mjs';
|
|
1
2
|
import { FUNCTIONS_RUNS_MIGRATION, FUNCTIONS_RUNS_MIGRATION_FILE, FUNCTIONS_REFERENCE_MIGRATION, FUNCTIONS_REFERENCE_MIGRATION_FILE, FUNCTIONS_META_MIGRATION, FUNCTIONS_META_MIGRATION_FILE, FUNCTIONS_MIGRATION, FUNCTIONS_MIGRATION_FILE, FUNCTIONS_CURSOR_MIGRATION, FUNCTIONS_CURSOR_MIGRATION_FILE } from '../worker/functions-schema.mjs';
|
|
2
3
|
import { MCP_MIGRATION, MCP_MIGRATION_FILE } from '../worker/mcp-store.mjs';
|
|
3
4
|
import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
|
|
@@ -820,6 +821,7 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
820
821
|
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
821
822
|
[`site/migrations/${SHOP_REMOVE_REFERRALS_FILE}`]: SHOP_REMOVE_REFERRALS,
|
|
822
823
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
824
|
+
[`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
|
|
823
825
|
[`site/migrations/${MCP_MIGRATION_FILE}`]: MCP_MIGRATION,
|
|
824
826
|
[`site/migrations/${FUNCTIONS_MIGRATION_FILE}`]: FUNCTIONS_MIGRATION,
|
|
825
827
|
[`site/migrations/${FUNCTIONS_CURSOR_MIGRATION_FILE}`]: FUNCTIONS_CURSOR_MIGRATION,
|
|
@@ -1027,8 +1029,13 @@ function ensureMcpMigration(root) {
|
|
|
1027
1029
|
if(existsSync(file))return null;
|
|
1028
1030
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,MCP_MIGRATION);return `site/migrations/${MCP_MIGRATION_FILE}`;
|
|
1029
1031
|
}
|
|
1032
|
+
function ensureToolDatabase(root) {
|
|
1033
|
+
const file=join(root,'site','migrations',TOOL_DATABASE_MIGRATION_FILE);
|
|
1034
|
+
if(existsSync(file))return null;
|
|
1035
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_DATABASE_MIGRATION);return `site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`;
|
|
1036
|
+
}
|
|
1030
1037
|
export function ensureMigrations(root) {
|
|
1031
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root)].filter(Boolean);
|
|
1038
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root)].filter(Boolean);
|
|
1032
1039
|
}
|
|
1033
1040
|
|
|
1034
1041
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
@@ -3450,6 +3450,48 @@
|
|
|
3450
3450
|
}
|
|
3451
3451
|
}
|
|
3452
3452
|
}
|
|
3453
|
+
},
|
|
3454
|
+
"0.50.7": {
|
|
3455
|
+
"files": {
|
|
3456
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3457
|
+
"README.md": "684b3cca8001673c",
|
|
3458
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3459
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3460
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3461
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3462
|
+
"posts/README.md": "37841aae476bd540",
|
|
3463
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3464
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3465
|
+
},
|
|
3466
|
+
"sections": {
|
|
3467
|
+
"AGENTS.md": {
|
|
3468
|
+
"(intro)": "394cbbe934e80722",
|
|
3469
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3470
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3471
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3472
|
+
"## Commands (all through the pinned CLI in node_modules)": "5222e08ca55e326e",
|
|
3473
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3474
|
+
"## Making games": "bb715272fd1a615b",
|
|
3475
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3476
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3477
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3478
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3479
|
+
"## Selling things (the shop)": "43a3556be93d1235",
|
|
3480
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3481
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3482
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3483
|
+
"## Beta": "9d02638207a26e73"
|
|
3484
|
+
}
|
|
3485
|
+
},
|
|
3486
|
+
"named": {
|
|
3487
|
+
"Homie Arcade": {
|
|
3488
|
+
"sections": {
|
|
3489
|
+
"AGENTS.md": {
|
|
3490
|
+
"## Commands (all through the pinned CLI in node_modules)": "611b437f5b3303f0"
|
|
3491
|
+
}
|
|
3492
|
+
}
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3453
3495
|
}
|
|
3454
3496
|
}
|
|
3455
3497
|
}
|
package/lib/tools-cli.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {localCredential} from './local-test.mjs';
|
|
1
2
|
import {existsSync,mkdirSync,writeFileSync,readFileSync,chmodSync} from 'node:fs';
|
|
2
3
|
import {join,dirname} from 'node:path';
|
|
3
4
|
import {createServer} from 'node:http';
|
|
@@ -16,6 +17,8 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
|
|
|
16
17
|
const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
|
|
17
18
|
const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
|
|
18
19
|
if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
|
|
20
|
+
const secret=localCredential(root,url);
|
|
21
|
+
if(secret){await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
|
|
19
22
|
const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
|
|
20
23
|
let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
|
|
21
24
|
// Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.50.
|
|
3
|
+
"version": "0.50.7",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/tools/tools.ts
CHANGED
|
@@ -7,7 +7,14 @@ export interface ToolContext {
|
|
|
7
7
|
office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
|
|
8
8
|
shop(): Promise<unknown>;
|
|
9
9
|
rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
|
|
10
|
-
database: {
|
|
10
|
+
database: {
|
|
11
|
+
get(key: string): Promise<unknown>;
|
|
12
|
+
getVersioned(key: string): Promise<{value: unknown; version: number} | null>;
|
|
13
|
+
put(key: string, value: unknown, options?: {version?: number}): Promise<{ok: boolean; version?: number; conflict?: boolean}>;
|
|
14
|
+
increment(key: string, amount?: number): Promise<{value: number; version: number}>;
|
|
15
|
+
append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
|
|
16
|
+
list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
|
|
17
|
+
};
|
|
11
18
|
fetch: typeof fetch;
|
|
12
19
|
fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
|
|
13
20
|
ai(model: string, input: unknown): Promise<unknown>;
|
package/worker/app-format.mjs
CHANGED
|
@@ -18,7 +18,7 @@ export function appProblems(meta) {
|
|
|
18
18
|
if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
|
|
19
19
|
for (const [id, c] of Object.entries(collections)) {
|
|
20
20
|
if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
|
|
21
|
-
for (const [field, spec] of Object.entries(c.fields)) if (
|
|
21
|
+
for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
|
|
22
22
|
}
|
|
23
23
|
for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
|
|
24
24
|
for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
|
|
@@ -32,6 +32,7 @@ export function appRole(meta, url) {
|
|
|
32
32
|
}
|
|
33
33
|
export function recordProblem(collection, data, { create = false } = {}) {
|
|
34
34
|
if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
|
|
35
|
+
for (const k of Object.keys(data)) if (!/^[a-z][a-z0-9_]*$/.test(k)) return `invalid field ${k}: names start with a lowercase letter and use lowercase letters, digits and underscore`;
|
|
35
36
|
for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
|
|
36
37
|
for (const [k, s] of Object.entries(collection.fields)) {
|
|
37
38
|
const v = data[k];
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
|
|
2
|
+
export const APP_STORAGE_JS=String.raw`(()=>{
|
|
3
|
+
for(const name of ['localStorage','sessionStorage']) {
|
|
4
|
+
try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
|
|
5
|
+
const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
|
|
6
|
+
const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
|
|
7
|
+
Object.defineProperty(window,name,{value:storage,configurable:true});
|
|
8
|
+
}
|
|
9
|
+
})();`;
|
package/worker/app-records.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {localSession} from './local-test.mjs';
|
|
1
2
|
import { toolIdentity } from './tool-identity.mjs';
|
|
2
3
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
3
4
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
@@ -38,12 +39,14 @@ export async function appAccess(request, env, meta, url) {
|
|
|
38
39
|
const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
|
|
39
40
|
if (!declared) return { ok: false, status: 404, error: 'unknown role' };
|
|
40
41
|
if (!declared.signIn) return { ok: true, role, can: declared.can };
|
|
42
|
+
const session = await localSession(request, env);
|
|
43
|
+
if (session?.app === meta.id && session.role === role) return {ok:true,role,can:declared.can};
|
|
41
44
|
if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
|
|
42
45
|
const caller = toolIdentity(request);
|
|
43
46
|
if (caller) {
|
|
44
47
|
if (caller.owner) return { ok: true, role, can: declared.can };
|
|
45
48
|
if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
46
|
-
const grant = await env.DB.prepare(
|
|
49
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
|
|
47
50
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
48
51
|
}
|
|
49
52
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
@@ -51,7 +54,7 @@ export async function appAccess(request, env, meta, url) {
|
|
|
51
54
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
|
52
55
|
const person = await players.of(request, env);
|
|
53
56
|
if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
54
|
-
const grant = await env.DB.prepare(
|
|
57
|
+
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, person.id).first();
|
|
55
58
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
56
59
|
}
|
|
57
60
|
export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
@@ -69,26 +72,30 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
69
72
|
if (!ID.test(b.player)) return fail(400, 'invalid account');
|
|
70
73
|
const p = await players.get(b.player, env);
|
|
71
74
|
if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
|
|
72
|
-
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
|
|
75
|
+
await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
|
|
73
76
|
}
|
|
74
77
|
const token = crypto.randomUUID().replaceAll('-', '');
|
|
75
78
|
if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
|
|
76
79
|
await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
|
|
77
80
|
const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
|
|
78
|
-
return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
81
|
+
return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
|
|
79
82
|
}
|
|
80
83
|
const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
|
|
84
|
+
if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
|
|
81
85
|
if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
|
|
82
86
|
if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
|
|
83
87
|
const access = await appAccess(request, env, meta, url);
|
|
84
88
|
if (!access.ok) return fail(access.status, access.error);
|
|
85
89
|
const [, collection, id] = match;
|
|
90
|
+
const session = await localSession(request, env);
|
|
91
|
+
const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
|
|
92
|
+
const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
|
|
86
93
|
const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
|
|
87
94
|
if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
|
|
88
95
|
if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
|
|
89
96
|
if (op === 'read') {
|
|
90
|
-
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(
|
|
91
|
-
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(
|
|
97
|
+
const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
|
|
98
|
+
: (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
|
|
92
99
|
return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
|
|
93
100
|
}
|
|
94
101
|
if (!id) return fail(400, 'record id required');
|
|
@@ -96,9 +103,9 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
|
|
|
96
103
|
if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
|
|
97
104
|
if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
|
|
98
105
|
const stmt = op === 'create'
|
|
99
|
-
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(
|
|
100
|
-
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(
|
|
101
|
-
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(
|
|
106
|
+
? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
|
|
107
|
+
: op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
|
|
108
|
+
: env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
|
|
102
109
|
const result = await stmt.run();
|
|
103
110
|
return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
|
|
104
111
|
} catch (error) {
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.50.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.50.7","sha256":"94d707956333f370dc0a5a65762e08418b6474d19302096c765b96090d9d4845"});
|
|
@@ -17,7 +17,7 @@ export async function readableFeed(env,cat,caller,name) {
|
|
|
17
17
|
if(!app||!app.records?.collections?.[feed.collection])return false;
|
|
18
18
|
if(caller.owner)return true;
|
|
19
19
|
if(launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return false;
|
|
20
|
-
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||caller.roles?.[app.id]?.includes(role)));
|
|
20
|
+
return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||(caller.roles?.[app.id]?.includes(role)||caller.roles?.['*']?.includes(role))));
|
|
21
21
|
}
|
|
22
22
|
// Non-record events have no app read capability; they remain owner-only.
|
|
23
23
|
return caller.owner===true;
|
|
@@ -56,12 +56,14 @@ export async function pushEvents(env,cat,origin) {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
/** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
|
|
59
|
+
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
59
60
|
export class EventStreams {
|
|
60
61
|
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
|
|
61
|
-
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream)this.stream=null;}
|
|
62
|
+
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
|
|
62
63
|
background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
|
|
63
64
|
async fetch(request) {
|
|
64
65
|
const input=await request.json();
|
|
66
|
+
if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
|
|
65
67
|
if(input.op==='stream') {
|
|
66
68
|
// A reconnect replaces the previous GET, including a half-open socket.
|
|
67
69
|
if(this.stream)this.close(this.stream);
|
|
@@ -73,6 +75,10 @@ export class EventStreams {
|
|
|
73
75
|
const stream={...input,after,name,controller:null,cancelled:false,resume:null};
|
|
74
76
|
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(new TextEncoder().encode(': connected\n\n'));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
75
77
|
this.stream=stream;
|
|
78
|
+
const caller=await streamCaller(this.env,connection).catch(()=>null);
|
|
79
|
+
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
80
|
+
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
81
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
|
|
76
82
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
77
83
|
// hold up the writer's request or a function handler.
|
|
78
84
|
this.background(input);
|
|
@@ -81,11 +87,16 @@ export class EventStreams {
|
|
|
81
87
|
this.background(input);return new Response(null,{status:204});
|
|
82
88
|
}
|
|
83
89
|
wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
|
|
90
|
+
async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
|
|
84
91
|
async deliver(input) {
|
|
85
92
|
const stream=this.stream;if(!stream)return;
|
|
86
93
|
let caller;
|
|
87
|
-
try{caller=await
|
|
94
|
+
try{caller=await streamCaller(this.env,input.connection);}
|
|
88
95
|
catch{this.close(stream);return;}
|
|
96
|
+
if(input.tools){
|
|
97
|
+
const signature=await this.toolSignature(input.tools,caller);
|
|
98
|
+
if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
|
|
99
|
+
}
|
|
89
100
|
const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
|
|
90
101
|
const subscriptions=[];
|
|
91
102
|
for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
|
|
@@ -114,3 +125,19 @@ export class EventStreams {
|
|
|
114
125
|
}
|
|
115
126
|
}
|
|
116
127
|
}
|
|
128
|
+
|
|
129
|
+
/** Refresh active streams whenever this Worker observes a new tool catalogue. */
|
|
130
|
+
export async function pushToolList(env,cat,tools,origin) {
|
|
131
|
+
if(!env.LOBBY)return;
|
|
132
|
+
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
133
|
+
const signature=JSON.stringify(publicShape);
|
|
134
|
+
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
135
|
+
if(previous?.value===signature)return;
|
|
136
|
+
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
137
|
+
let after='';
|
|
138
|
+
for(;;){
|
|
139
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
140
|
+
for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
|
|
141
|
+
if(rows.length<100)break;after=rows.at(-1).key;
|
|
142
|
+
}
|
|
143
|
+
}
|
package/worker/functions.mjs
CHANGED
|
@@ -74,8 +74,11 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
74
74
|
if (!claimed.meta.changes) continue;
|
|
75
75
|
let failure=null;
|
|
76
76
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
77
|
+
const stored=JSON.parse(row.data);
|
|
78
|
+
const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
|
|
79
|
+
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
|
|
80
|
+
const testScope=isolated?isolated[2]:null;
|
|
81
|
+
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
|
|
79
82
|
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
80
83
|
const finished=now();
|
|
81
84
|
await env.DB.batch([
|
package/worker/index.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {APP_STORAGE_JS} from './app-frame.mjs';
|
|
2
|
+
import {recordsPage} from './records-page.mjs';
|
|
1
3
|
import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
|
|
2
4
|
import { characterStore, characterRequest } from './characters.mjs';
|
|
3
5
|
export { useFunctions } from './functions.mjs';
|
|
@@ -529,8 +531,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
529
531
|
...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
|
|
530
532
|
};
|
|
531
533
|
let html = await res.text();
|
|
532
|
-
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;
|
|
533
|
-
const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
|
+
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;bottom:16px;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
|
|
535
|
+
const head = (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
534
536
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
535
537
|
return new Response(html, {
|
|
536
538
|
headers: {
|
|
@@ -610,6 +612,7 @@ export default {
|
|
|
610
612
|
async scheduled(event, env, ctx) {
|
|
611
613
|
const maintenance=(async () => {
|
|
612
614
|
const cat = await catalogue(env, 'https://studio.invalid');
|
|
615
|
+
await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
|
|
613
616
|
await reconcileOrders(env, shopOf(cat));
|
|
614
617
|
await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
|
|
615
618
|
})();
|
|
@@ -638,6 +641,8 @@ export default {
|
|
|
638
641
|
try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
|
|
639
642
|
res = json({ ok: false, error: 'failed' }, 500);
|
|
640
643
|
} finally {
|
|
644
|
+
// Refresh after the route has populated the catalogue cache.
|
|
645
|
+
if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
|
|
641
646
|
// A route can commit a write and then fail. Its outbox still needs delivery.
|
|
642
647
|
if(env.DB) {
|
|
643
648
|
const delivery=dispatch();
|
|
@@ -649,6 +654,11 @@ export default {
|
|
|
649
654
|
},
|
|
650
655
|
};
|
|
651
656
|
|
|
657
|
+
async function notifyTools(env,cat,origin) {
|
|
658
|
+
if(!env.DB||!env.LOBBY)return;
|
|
659
|
+
const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
|
|
660
|
+
await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
|
|
661
|
+
}
|
|
652
662
|
let studioTools = async () => [];
|
|
653
663
|
/** Studio-authored Worker code, loaded only on the MCP surface. */
|
|
654
664
|
export function useTools(load) { studioTools = load; }
|
|
@@ -920,6 +930,7 @@ async function route(request, env, ctx) {
|
|
|
920
930
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
921
931
|
return json({ ok: false, error: a.error }, a.status);
|
|
922
932
|
}
|
|
933
|
+
if(!meta.room)return recordsPage(meta,url,cat);
|
|
923
934
|
}
|
|
924
935
|
}
|
|
925
936
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
|
+
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
|
+
export async function localCaller(request, env) {
|
|
4
|
+
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
|
+
return {id:'local-test-owner',owner:true,client:null};
|
|
6
|
+
}
|
|
7
|
+
export async function localSession(request, env) {
|
|
8
|
+
if (!local(request, env)) return null;
|
|
9
|
+
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
10
|
+
if (!token) return null;
|
|
11
|
+
try {
|
|
12
|
+
const [data, signature] = token.split('.');
|
|
13
|
+
if (!/^[a-f0-9]{64}$/.test(signature)) return null;
|
|
14
|
+
const key = await crypto.subtle.importKey('raw',new TextEncoder().encode(env.HOMIE_LOCAL_TEST_SECRET),{name:'HMAC',hash:'SHA-256'},false,['verify']);
|
|
15
|
+
if (!await crypto.subtle.verify('HMAC',key,Uint8Array.from(signature.match(/../g),h=>parseInt(h,16)),new TextEncoder().encode(data))) return null;
|
|
16
|
+
const session = JSON.parse(atob(data.replaceAll('-','+').replaceAll('_','/')));
|
|
17
|
+
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
18
|
+
} catch { return null; }
|
|
19
|
+
}
|