@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/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://127.0.0.1:${st.port}`, port: st.port, pid: st.rec.pid ?? st.pids[0] };
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 files = [join(g.dir, g.kind === 'app' ? 'app.json' : 'game.json'), join(g.dir, 'tunables.json')];
296
- for (const sub of ['src', 'map']) {
297
- const dir = join(g.dir, sub);
298
- if (existsSync(dir)) for (const name of readdirSync(dir, { recursive: true })) files.push(join(dir, String(name)));
299
- }
300
- let newest = 0;
301
- let n = 0;
302
- for (const f of files) { try { const st = statSync(f); if (st.isFile()) { n += 1; newest = Math.max(newest, st.mtimeMs); } } catch { /* gone */ } }
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://127.0.0.1:${st.port}/` : 'an unknown port';
320
- return { ok: false, command: 'dev', already: true, ...(st.port ? { url: `http://127.0.0.1:${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}` };
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://127.0.0.1:${port}/`);
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
- log(`Local site: ${origin}/ (each game: ${origin}/<id>/play — open it in two browsers)`);
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 args = ['dev', ...(remoteAi ? [] : ['--local']), '--ip', lan ? '0.0.0.0' : '127.0.0.1', '--port', port, '--var', 'HOMIE_EMBED_PREVIEW:1', ...ai.args, ...localVars];
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).filter(isRulesGame).map((g) => [g.id, rulesStamp(g)]));
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).filter(isRulesGame)) {
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;
@@ -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://127.0.0.1:8787 for `npm run dev`' };
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.6",
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: { get(key: string): Promise<unknown>; put(key: string, value: unknown): Promise<void> };
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>;
@@ -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 (!key.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`);
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
+ })();`;
@@ -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('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, caller.id).first();
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('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, person.id).first();
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(meta.id, collection, id).first()].filter(Boolean)
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(meta.id, collection).all()).results;
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(meta.id, collection, id, JSON.stringify(body.data), Date.now())
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(meta.id, collection, id, JSON.stringify(body.data), Date.now(), body.version)
101
- : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(meta.id, collection, id, body.version);
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) {
@@ -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.6","sha256":"8aabf4298c649e24766b5dbd5836625be78a4c69c1005c221d93d4c9e4e45b3e"});
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 currentCaller(this.env,{connection:input.connection});}
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
+ }
@@ -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 event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
78
- await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
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;top:80px;padding:16px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
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
+ }