@homie-rocks/studio 0.55.0 → 0.56.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/README.md +2 -0
  3. package/bin/homie-studio.mjs +1 -1
  4. package/docs/custom-routes.md +72 -0
  5. package/guides/app/SKILL.md +7 -0
  6. package/guides/playtest/scripts/playtest.mjs +7 -5
  7. package/lib/check.mjs +8 -1
  8. package/lib/cloudflare.mjs +49 -22
  9. package/lib/dev.mjs +6 -8
  10. package/lib/local-database-start.mjs +6 -0
  11. package/lib/mcp-tools.mjs +3 -3
  12. package/lib/port-check.mjs +3 -2
  13. package/lib/scaffold.mjs +2 -0
  14. package/lib/template-history.json +46 -1
  15. package/lib/worker-build.mjs +4 -1
  16. package/lib/worker-config.mjs +7 -0
  17. package/netplay/NETPLAY.md +5 -1
  18. package/netplay/netplay.ts +24 -9
  19. package/package.json +1 -1
  20. package/rules/core.ts +9 -0
  21. package/rules/host.ts +40 -11
  22. package/rules/view.ts +1 -0
  23. package/tools/TOOLS.md +6 -1
  24. package/worker/background-queue.mjs +1 -0
  25. package/worker/build-id.mjs +1 -1
  26. package/worker/custom-routes.mjs +16 -0
  27. package/worker/gate.mjs +94 -23
  28. package/worker/idle-chat.mjs +56 -0
  29. package/worker/index.mjs +2 -0
  30. package/worker/lazy-objects.mjs +3 -0
  31. package/worker/lobby-peak.mjs +44 -0
  32. package/worker/lobby-shards.mjs +43 -0
  33. package/worker/lobby-store.mjs +35 -0
  34. package/worker/lobby-summary.mjs +28 -0
  35. package/worker/lobby.mjs +86 -18
  36. package/worker/lounge-store.mjs +2 -2
  37. package/worker/match-router.mjs +74 -0
  38. package/worker/mcp-room.mjs +2 -1
  39. package/worker/mcp-tools.mjs +4 -2
  40. package/worker/office.mjs +2 -1
  41. package/worker/operational-schema.mjs +2 -0
  42. package/worker/pages.mjs +1 -1
  43. package/worker/relay-channel.mjs +119 -0
  44. package/worker/request-db.mjs +4 -3
  45. package/worker/room-chat.mjs +2 -1
  46. package/worker/room-sessions.mjs +65 -0
  47. package/worker/room.mjs +16 -9
  48. package/worker/route-auth.mjs +22 -0
  49. package/worker/routes.mjs +7 -4
  50. package/worker/runtime-support.mjs +5 -3
  51. package/worker/session-codec.mjs +24 -0
  52. package/worker/stats.mjs +2 -1
  53. package/worker/table.mjs +277 -41
  54. package/worker/version.mjs +1 -1
  55. package/worker/watch-sockets.mjs +75 -0
package/CHANGELOG.md CHANGED
@@ -17,6 +17,45 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.56.0] - 2026-10-15
21
+
22
+ **Plugin 0.56.0**
23
+
24
+ Studios scale with hibernating rooms, add custom routes with built-in access checks, and deploy reliably with background queue handlers.
25
+
26
+ ### Added
27
+
28
+ - Studio Worker `useRoutes` hook for custom APIs, well-known files and callbacks, after Homie's matched routes and before Worker asset fallback, with response security headers.
29
+ - Public `isOwner(request, env)` and `hasRole(request, env, app, role)` authorization helpers using existing live session and app access checks.
30
+ - Secret capabilities may use `self` for the runtime studio origin, with origin and redirect restrictions retained.
31
+
32
+ ### Changed
33
+
34
+ - Renew relay sequence epochs whenever a Gate opens a link, probe the room after a missed round start, refill quiet games across all matching pools, and coalesce peak increases without writing unchanged room reports.
35
+ - Recover Gate relay transport failures with ordered backoff and bounded output queues; remove dead sessions so players can reconnect. Keep idle heartbeats at the edge through long breaks, exclude transport telemetry from session writes, and retain only QA markers from relay user agents. Match quiet public games in one pool, spread bursts through leased shard routing, and calculate daily peaks from concurrent shard totals.
36
+ - Close failed room restores, detect unanswered clock pings, preserve live Gate multiplexing, bound crowd session storage, and avoid unchanged idle writes. Restore watcher rate limits without replay, shard matchmaking, and paginate aggregated room summaries.
37
+ - Add an opt-in rules-host round-idle contract: finish both scoring-settlement ticks, persist the next-round deadline, and resume without simulating idle time.
38
+ - Store Lobby rooms as SQLite rows and deliver room summaries through Queues. Hibernate player and watch sockets, including Gates and server-rule rooms, between settled rounds. Retain logical sessions and moderation state, detach idle relay streams, and restore complete world headers without repeating admission.
39
+
40
+ ### Fixed
41
+
42
+ - Custom studio Workers that wrap only `fetch` now inherit missing toolkit queue and scheduled handlers in the generated entry, so background queue consumers attach during deploy. Custom handlers remain intact.
43
+ - Deploy failures retain Wrangler's real error and context with secrets redacted, instead of showing only the last six lines.
44
+ - Custom route role checks enforce fresh app launch gates. Custom Worker asset routes preserve generated authorization paths; same-origin mutation protection is exported and documented with reserved `/api/custom/*` paths and app cookie scope.
45
+ - Custom `assets.run_worker_first` entries cannot exclude paths with `!`; the build refuses them so private app files always stay behind their access checks.
46
+ - Late link subscriptions no longer trigger rules-view reconnect recovery; throwing listeners still return their unsubscribe function.
47
+ - MCP publishing and its preflight use the connector's directory. Wrangler sign-in checks stop immediately when logged out and retry refresh collisions without blocking the event loop, including CLI status.
48
+ - Scaffold, local database and login regression tests use OS temporary directories and work from the package test command.
49
+ - New studios leave directory listing opt-in; existing explicit directory choices are preserved.
50
+ - Deploy and storage sign-in checks retry shared Wrangler OAuth refresh races with bounded backoff and report the original diagnostic, distinguishing authentication from temporary check failures.
51
+ - Room-link listeners immediately receive current state, so wall UIs mounted after welcome do not stay on “Connecting to Homie.”
52
+ - Local dev refuses to serve until migrations and function registration succeed, preserving first-notice delivery on a fresh database.
53
+
54
+ ### Upgrade notes
55
+
56
+ - `net.on('link', listener)` now calls the listener synchronously on subscription with `why: 'subscribe'` and `prev === state`, then reports changes as before.
57
+ - Custom routes and authorization examples are in `packages/studio/docs/custom-routes.md` (the installed package uses `docs/custom-routes.md`).
58
+
20
59
  ## [0.55.0] - 2026-10-15
21
60
 
22
61
  **Plugin 0.55.0**
package/README.md CHANGED
@@ -8,6 +8,8 @@ to its own Cloudflare account with Wrangler. The homie.rocks directory lists the
8
8
 
9
9
  Apps are one morphing screen, its roles and its parts: a customer phone, a staff tablet and a wall can share a 3D scene without rounds or scores. `homie-studio app new welcome` creates a working welcome queue in `apps/welcome/`; install its declared dependencies, build, then use `dev --lan` and the printed Wi-Fi address. `check welcome --url <origin>` proves a shared action and reconnect across three screens. Staff permissions, lasting D1 records, public QR/link helpers and customer standalone builds use the existing studio machinery. See [the app guide](apps/APPS.md).
10
10
 
11
+ For studio-specific JSON APIs, well-known files and OAuth callbacks, see [custom Worker routes and authorization](docs/custom-routes.md).
12
+
11
13
  Most people never run this by hand: the Homie plugin for Claude Code, Codex and Grok does,
12
14
  and the person approves Cloudflare once in their browser.
13
15
 
@@ -973,7 +973,7 @@ async function main() {
973
973
  }
974
974
  if (cmd === 'status') {
975
975
  const studio = readStudio(root);
976
- return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && whoami(root)) };
976
+ return { ok: true, command: 'status', root, studio, site: siteUrl(root, studio), games: listGames(root).map((g) => g.id), cloudflareSignedIn: Boolean(wranglerBin(root) && await whoami(root)) };
977
977
  }
978
978
  if (cmd === 'upgrade') {
979
979
  const plan = upgradePlan(root);
@@ -0,0 +1,72 @@
1
+ # Studio Worker routes
2
+
3
+ In `site/src/worker.mjs`, register one handler at module evaluation. Keep the
4
+ existing exports and registrations. Do not wrap the default export's `fetch`.
5
+
6
+ ```js
7
+ import {useRoutes, isOwner, hasRole, requireSameOrigin} from '@homie-rocks/studio/worker';
8
+
9
+ useRoutes(async (request, env, ctx) => {
10
+ const url = new URL(request.url);
11
+ if (url.pathname === '/.well-known/example.json') {
12
+ return Response.json({name: 'My studio'});
13
+ }
14
+ if (url.pathname === '/api/custom/refresh') {
15
+ if (request.method !== 'POST') return new Response('POST required', {status:405});
16
+ const refused = requireSameOrigin(request);
17
+ if (refused) return refused;
18
+ if (!(await isOwner(request, env))) return new Response('Unauthorized', {status: 401});
19
+ // Validate input, then perform the owner-authorized mutation here.
20
+ return Response.json({ok: true});
21
+ }
22
+ // Return undefined or null to continue to the studio's static assets / 404.
23
+ });
24
+ ```
25
+
26
+ Homie's matched routes always take precedence, including its office, OAuth,
27
+ MCP, discovery, app and game routes, and their authorization refusals. Use a
28
+ separate namespace under `/api/custom/`, such as `/api/custom/callbacks/provider` for your OAuth callback;
29
+ `/oauth/*` belongs to Homie. Unmatched requests reach your handler before static
30
+ assets inside the Worker. Cloudflare normally serves existing public static files
31
+ without invoking the Worker. If a custom route must override such a file, put
32
+ `{"assets":{"run_worker_first":true}}` in the studio's `wrangler.custom.json`;
33
+ build/deploy preserves that setting. To intercept only selected files, use
34
+ `{"assets":{"run_worker_first":["/feed.json"]}}`. These paths are unioned with
35
+ Homie's generated `/games/*`, `/_studio/*`, `/_site/*` and per-app paths. Those
36
+ paths must reach the Worker to enforce private app authorization; custom arrays
37
+ (including empty arrays) or `false` cannot remove them. Exclusion patterns (starting with `!`) are refused at build, because they would take those paths out from behind their access checks. A second `useRoutes` call replaces the handler; `useRoutes(null)` removes
38
+ it. Register configuration only, never request data, at module scope.
39
+
40
+ The handler may return a `Response`, `null` or `undefined`. Errors propagate as
41
+ Worker errors. Responses keep streaming and pass through Homie's normal HTML
42
+ security headers, concept-mode handling and database change delivery. Custom
43
+ responses also receive `X-Content-Type-Options: nosniff` and a default
44
+ `Referrer-Policy: strict-origin-when-cross-origin`. WebSocket upgrades are preserved.
45
+ Use `ctx.waitUntil(promise)` for work that must finish after the response.
46
+
47
+ `await isOwner(request, env)` recognizes the live owner session issued by passkey
48
+ sign-in (including expiry and revocation), configured owner player accounts, and
49
+ trusted owner tool identity. Read/office bearer keys alone do not make a browser
50
+ the owner. `await hasRole(request, env, appId, roleName)` uses the app's declared
51
+ role and existing access checks: public roles permit visitors; private roles
52
+ require the same private link or handoff session and live grants as the app.
53
+ Unknown apps and roles return false. The explicit role argument takes precedence
54
+ over query parameters. These helpers authorize identity; custom mutations must
55
+ also validate the request method, Origin/CSRF protection, input and OAuth state.
56
+
57
+ `requireSameOrigin(request)` reuses the studio's browser Origin checks and returns
58
+ `null` on success or a 403 `Response` to return immediately. Use it with an explicit
59
+ POST (or other mutation method) check before cookie-authorized changes.
60
+
61
+ Use `/api/custom/*` for studio-owned endpoints: `api` is reserved from game and app
62
+ IDs, so future experiences cannot take over that namespace. An app handoff cookie
63
+ is scoped to `/<app>/`; the browser does **not** send it to `/custom-api/...` or
64
+ `/api/custom/...`. For app role mutations, use the built-in `/<app>/api/app/records/*`
65
+ endpoints or an app tool with its existing grant checks. A studio-wide custom
66
+ endpoint can use the owner session; do not broaden app cookies or assume a handoff
67
+ cookie will authorize `hasRole` outside its app path. Private/invite launches
68
+ also require launch access even for a declared anonymous visitor role.
69
+
70
+ For function HTTP calls whose destination is `self`, use direct `/api/` endpoints
71
+ such as `/api/custom/refresh`, not page URLs that redirect. Self calls use
72
+ `redirect:'error'`; a redirect fails instead of forwarding credentials.
@@ -365,3 +365,10 @@ Authoritative bookings/inventory are server-side `defineTool` actions with stric
365
365
  Initialise text controls and navigation before 3D rendering. Catch renderer creation failures inside the scene section; keep fallback text in normal flow, never over navigation. Add a navigation need and run `homie-studio check <id> --webgl-failure --shots .checks/no-webgl` to prove the controls work without WebGL.
366
366
 
367
367
  On TV, reserve `--homie-join-inset-bottom/right` (or the configured corner's top/left) plus a gap; `body.homie-join-visible` and `--homie-join-x/y/width/height` describe the shell card in the app frame too. See `node_modules/@homie-rocks/studio/docs/toolkit-place-patterns.md` for layout and text-first examples, connector setup and diagnostics.
368
+
369
+ For studio-owned HTTP routes, read
370
+ `node_modules/@homie-rocks/studio/docs/custom-routes.md`. Register with `useRoutes`
371
+ and use `/api/custom/*` (game/app IDs cannot claim `api`). App handoff cookies are
372
+ scoped to `/<app>/` and do not reach `/custom-api/...`; use built-in app record
373
+ endpoints or app tools for role-authorized mutations. Use direct `/api/` endpoints
374
+ for function `self` calls, which use `redirect:'error'`.
@@ -542,17 +542,19 @@ async function soundSession(base, game, out, seconds, gameJson) {
542
542
  } finally { await close(h); }
543
543
  }
544
544
 
545
- /** A round with one person playing hard (a computer) and one doing nothing (a phone), both strangers in the public room. */
545
+ /** A round with one person playing hard (a computer) and one doing nothing (a phone), in the same public room. */
546
546
  async function playSession(base, game, out, roundSeconds, gameJson) {
547
547
  const profile = actionProfile(gameJson, 'desk');
548
548
  const actions = newActions(profile);
549
549
  const active = await launch('desk', `${base}/${game}/play`);
550
- const idle = await launch('phone', `${base}/${game}/play`);
550
+ let idle;
551
551
  try {
552
552
  const seat = async (h) => { const until = Date.now() + 45_000; while (Date.now() < until) { const s = await shell(h); if (s?.room && s.seat !== null && s.seat !== undefined && s.role) return s; await sleep(300); } return null; };
553
- const [sa, si] = await Promise.all([seat(active), seat(idle)]);
553
+ const sa = await seat(active);
554
+ idle = await launch('phone', `${base}/${game}/play${sa?.room ? `?room=${encodeURIComponent(sa.room)}` : ''}`);
555
+ const si = await seat(idle);
554
556
  if (!sa || !si) { row('play', 'FAIL', { why: 'a browser never got a seat in 45 s' }); return; }
555
- if (sa.room !== si.room) { row('play', 'FAIL', { why: `the two browsers landed in different rooms (${sa.room}, ${si.room}): two strangers who press Play must meet` }); return; }
557
+ if (sa.room !== si.room) { row('play', 'FAIL', { why: `the two browsers landed in different rooms (${sa.room}, ${si.room}): the invitation must join the same room` }); return; }
556
558
  const since = Date.now();
557
559
  // Only a round that STARTS after both are seated counts: a newcomer takes a bot's place mid-round and inherits
558
560
  // its body and score, so the round already running says nothing about how these two played.
@@ -597,7 +599,7 @@ async function playSession(base, game, out, roundSeconds, gameJson) {
597
599
  results: rowsR.map((x) => ({ place: x.place, score: x.score, bot: x.bot, who: x.seat === sa.seat ? 'the active player' : x.seat === si.seat ? 'the idle player' : x.bot ? 'a bot' : 'another person' })),
598
600
  scoreSamples: scores.filter((_, k) => k % 5 === 0).slice(0, 40), shot: 'play-round-over.png',
599
601
  });
600
- } finally { await close(active); await close(idle); }
602
+ } finally { await close(active); if (idle) await close(idle); }
601
603
  }
602
604
 
603
605
  /** The studio's own checks, run as the creator would: the owner tests (port check) and the two-stranger round. */
package/lib/check.mjs CHANGED
@@ -203,7 +203,14 @@ export async function check({ webglFailure = false, isolate = true, root = null,
203
203
  const ua = await browser.userAgent();
204
204
  await page.setUserAgent(`${ua} ${userAgentTag}`);
205
205
  const t0 = Date.now();
206
- try { await page.goto(play, { waitUntil: 'domcontentloaded', timeout: 90_000 }); } catch (error) {
206
+ let target = play;
207
+ if (players.length) {
208
+ const first = players[0].page;
209
+ await first.waitForFunction(() => Boolean(window.__shell?.room), {timeout: 90_000});
210
+ const room = await first.evaluate(() => window.__shell.room);
211
+ target = `${play}?room=${encodeURIComponent(room)}`;
212
+ }
213
+ try { await page.goto(target, { waitUntil: 'domcontentloaded', timeout: 90_000 }); } catch (error) {
207
214
  if (cannotAsk) return { ...cannotAsk, play, why: `${cannotAsk.why} (The ${kind.name} browser could not open it either: ${String(error?.message ?? error).split('\n')[0].slice(0, 120)}.)` };
208
215
  throw error;
209
216
  }
@@ -1,3 +1,4 @@
1
+ import {redactText} from './redact.mjs';
1
2
  import {enableReadReplication} from './read-replication.mjs';
2
3
  import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
3
4
  import { directoryUrl } from '../worker/directory-choice.mjs';
@@ -53,6 +54,13 @@ import { LOCAL_STATE, configPath, domainOrigin, isRulesGame, isWorkersDev, layou
53
54
 
54
55
  const ANSI = /\u001b\[[0-9;]*m/g;
55
56
 
57
+ /** Keep Wrangler's error and its context; progress/footer lines must not hide it. */
58
+ export function wranglerDeployError(out) {
59
+ const clean = redactText(String(out ?? '').replace(ANSI, '')).text;
60
+ const error = clean.search(/(?:^|\n)[^\n]*\[ERROR\]/);
61
+ return (error >= 0 ? clean.slice(error) : clean).trim();
62
+ }
63
+
56
64
  export function experienceAddresses(root, id, url) {
57
65
  const app = listGames(root).find(g => g.id === id)?.kind === 'app';
58
66
  return { [app ? 'open' : 'play']: url ? `${url}/${id}/${app ? 'open' : 'play'}` : null, wall: url ? `${url}/${id}/tv` : null };
@@ -67,24 +75,41 @@ export function wranglerBin(root) {
67
75
  export function runner(root, env) {
68
76
  const bin = wranglerBin(root);
69
77
  if (!bin) throw new Error('Wrangler is not installed in this studio yet: run `npm install` in the studio folder first');
70
- return (args, { cwd = workerDir(root), input } = {}) => {
78
+ return (args, { cwd = workerDir(root), input, timeout = 10 * 60_000 } = {}) => {
71
79
  const dev = join(workerDir(root), '.wrangler', 'homie-dev.wrangler.json');
72
80
  if (args.includes('--local') && !args.includes('--config') && existsSync(dev)) args = [...args, '--config', dev, '--persist-to', join(workerDir(root), '.wrangler', 'state')];
73
81
  const res = spawnSync(bin, args, {
74
82
  cwd, env: { ...process.env, ...env, WRANGLER_SEND_METRICS: 'false', CI: '1' }, encoding: 'utf8',
75
- input, maxBuffer: 64 * 1024 * 1024, timeout: 10 * 60_000,
83
+ input, maxBuffer: 64 * 1024 * 1024, timeout,
76
84
  });
77
- const out = `${res.stdout ?? ''}${res.stderr ?? ''}`.replace(ANSI, '');
85
+ const out = `${res.stdout ?? ''}${res.stderr ?? ''}${res.error ? '\n' + res.error.message : ''}`.replace(ANSI, '');
78
86
  return { code: res.status ?? 1, out, stdout: (res.stdout ?? '').replace(ANSI, '') };
79
87
  };
80
88
  }
81
89
 
90
+ /** Retry with fresh Wrangler processes: another studio may be rotating shared OAuth credentials. */
91
+ export async function checkWranglerLogin(root, env = {}, {run = null, sleep = ms => new Promise(resolve => setTimeout(resolve, ms))} = {}) {
92
+ const bin = run ? null : wranglerBin(root);
93
+ if (!run && !bin) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not installed in this studio yet: run `npm install` first.'};
94
+ const w = run ?? (args => wranglerRun(bin, args, {cwd:root, env:{...process.env,...env,WRANGLER_SEND_METRICS:'false',CI:'1'}, timeout:25_000}));
95
+ let res, data;
96
+ for (let attempt = 0; attempt < 4; attempt++) {
97
+ if (attempt) await sleep([200, 600, 1400][attempt - 1] + Math.floor(Math.random() * 100));
98
+ res = await w(['whoami', '--json'], {cwd: root, timeout: 25_000});
99
+ try { data = JSON.parse(res.stdout ?? ''); } catch { data = null; }
100
+ if (res.code === 0 && data?.loggedIn === false) return {ok:false, needs:'cloudflare-login', why:'Wrangler is not signed in. Run `npx wrangler login` and approve once in your browser, then retry.'};
101
+ if (res.code === 0 && data?.loggedIn === true) return {ok:true, who:{accounts:(data.accounts ?? []).map(a => ({id:a.id,name:a.name})),authType:data.authType}};
102
+ }
103
+ const detail = String(res.out ?? '').trim();
104
+ const expired = data?.loggedIn === false || /(?:refresh token.*(?:expired|invalid)|invalid_grant|not (?:logged|signed) in|authentication error|authenticate request)/i.test(detail);
105
+ return {ok:false, needs:expired ? 'cloudflare-login' : 'cloudflare-auth-check', why: (expired
106
+ ? 'Wrangler could not authenticate after four attempts. Run `npx wrangler login`, then retry.'
107
+ : 'Wrangler could not check sign-in after four attempts. Another process may be refreshing credentials, or Cloudflare may be unreachable. Retry shortly.') + (detail ? '\nWrangler: '+detail : '')};
108
+ }
109
+
82
110
  /** Who Wrangler is signed in as, or null. Never prints a token. */
83
- export function whoami(root, env = {}) {
84
- const w = runner(root, env);
85
- const res = w(['whoami', '--json'], { cwd: root });
86
- if (res.code !== 0) return null;
87
- try { const d = JSON.parse(res.stdout); return d.loggedIn ? { accounts: (d.accounts ?? []).map((a) => ({ id: a.id, name: a.name })), authType: d.authType } : null; } catch { return null; }
111
+ export async function whoami(root, env = {}) {
112
+ return (await checkWranglerLogin(root, env)).who ?? null;
88
113
  }
89
114
 
90
115
  function parseJson(text) {
@@ -262,12 +287,13 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
262
287
  const wide = wideRouteRefusal(readConfig(root), studio);
263
288
  if (wide) return { ok: false, command: 'deploy', ...wide };
264
289
  let routes = keptRoutes(root, studio);
265
- const who = whoami(root);
266
- if (!who) {
267
- if (cf.auth === 'stripe-projects') {
268
- return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
290
+ const login = await checkWranglerLogin(root);
291
+ const who = login.who;
292
+ if (!login.ok) {
293
+ if (cf.auth === 'stripe-projects' && login.needs === 'cloudflare-login') {
294
+ return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: login.why + '\nWrangler did not accept the Cloudflare token Stripe Projects keeps for this studio (studio.json "auth": "stripe-projects"). Run `stripe projects env --pull` in the studio folder (it rewrites the git-ignored .env from Projects\' vault), then `npm run deploy` again; or sign in the usual way (`npx wrangler login`) and remove "auth" from studio.json.' };
269
295
  }
270
- return { ok: false, command: 'deploy', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` in the studio folder: it opens Cloudflare in the browser and the person approves once (a free account works, no payment method). Then run `npm run deploy` again.' };
296
+ return { ...login, command: 'deploy' };
271
297
  }
272
298
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
273
299
  if (!accountId) {
@@ -413,7 +439,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
413
439
  step(address.created ? 'registered the account’s workers.dev address automatically' : 'kept the account’s existing workers.dev address');
414
440
  dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
415
441
  }
416
- if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
442
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
417
443
  if (paidReleases(root).length) {
418
444
  const bin = wranglerBin(root);
419
445
  const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
@@ -637,7 +663,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
637
663
  }
638
664
  const started = Date.now();
639
665
  const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
640
- if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
666
+ if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
641
667
  step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
642
668
  const repo = repoOf(root);
643
669
  step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
@@ -683,8 +709,9 @@ export async function storageAdd(root, { log = () => {} } = {}) {
683
709
  if (created.has(`r2:${bucket}`)) {
684
710
  return { ok: true, command: 'storage add', bucket, already: true, next: ['npm run deploy (binds it as MEDIA if the live site does not have it yet)'] };
685
711
  }
686
- const who = whoami(root);
687
- if (!who) return { ok: false, command: 'storage add', needs: 'cloudflare-login', why: 'Wrangler is not signed in to Cloudflare. Run `npx wrangler login` first (one approval in the browser).' };
712
+ const login = await checkWranglerLogin(root);
713
+ if (!login.ok) return {...login, command:'storage add'};
714
+ const who = login.who;
688
715
  const accountId = process.env.CLOUDFLARE_ACCOUNT_ID || cf.accountId || (who.accounts.length === 1 ? who.accounts[0].id : null);
689
716
  if (!accountId) return { ok: false, command: 'storage add', needs: 'cloudflare-account', why: 'This login reaches several Cloudflare accounts; put the studio\'s in studio.json (cloudflare.accountId) first.', accounts: who.accounts };
690
717
  const w = runner(root, { CLOUDFLARE_ACCOUNT_ID: accountId });
@@ -718,15 +745,15 @@ export async function storageAdd(root, { log = () => {} } = {}) {
718
745
  }
719
746
 
720
747
  /** One Wrangler command, run without blocking (an upload of a music video takes a while); the last of its words kept. */
721
- function wranglerRun(bin, args, { cwd, env }) {
748
+ function wranglerRun(bin, args, { cwd, env, timeout }) {
722
749
  return new Promise((done) => {
723
- const p = spawn(bin, args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] });
724
- let out = '';
750
+ const p = spawn(bin, args, { cwd, env, timeout, stdio: ['ignore', 'pipe', 'pipe'] });
751
+ let out = '', stdout = '';
725
752
  const keep = (d) => { out = `${out}${d}`.slice(-8000); };
726
- p.stdout.on('data', keep);
753
+ p.stdout.on('data', data => {stdout = `${stdout}${data}`.slice(-65536); keep(data);});
727
754
  p.stderr.on('data', keep);
728
755
  p.on('error', (e) => done({ code: 1, out: String(e.message) }));
729
- p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, '') }));
756
+ p.on('close', (code) => done({ code: code ?? 1, out: out.replace(ANSI, ''), stdout: stdout.replace(ANSI, '') }));
730
757
  });
731
758
  }
732
759
 
package/lib/dev.mjs CHANGED
@@ -347,17 +347,15 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
347
347
  if (!bin) return { ok: false, command: 'dev', why: 'run npm install in the studio first' };
348
348
  const env = { ...process.env, WRANGLER_SEND_METRICS: 'false', CI: '1', ...(process.stdout.isTTY && !process.env.NO_COLOR ? { FORCE_COLOR: process.env.FORCE_COLOR ?? '1' } : {}) };
349
349
  for (const added of ensureMigrations(root)) log(`added ${added} (${migrationWord(added)})`);
350
- await new Promise((done) => {
351
- const m = spawn(bin, ['d1', 'migrations', 'apply', 'DB', '--local'], { cwd: workerDir(root), env, stdio: ['ignore', 'ignore', 'inherit'] });
352
- m.on('close', done);
353
- m.on('error', done);
354
- });
355
350
  const {functionDeploymentSQL}=await import('./functions-build.mjs');
356
- const registerFunctions=()=>new Promise(resolve=>{
357
- const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
351
+ const {prepareLocalDatabase}=await import('./local-database-start.mjs');
352
+ const runLocal=args=>new Promise(resolve=>{
353
+ const child=spawn(bin,args,{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
358
354
  child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
359
355
  });
360
- if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
356
+ const registerFunctions=()=>runLocal(['d1','execute','DB','--local','--command',functionDeploymentSQL(root)]);
357
+ const failure=await prepareLocalDatabase({migrate:()=>runLocal(['d1','migrations','apply','DB','--local']),register:registerFunctions});
358
+ if(failure)return {ok:false,command:'dev',why:failure};
361
359
  const origin = `http://127.0.0.1:${port}`;
362
360
  const printedOrigin = `http://localhost:${port}`;
363
361
  log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
@@ -0,0 +1,6 @@
1
+ /** Complete local schema and subscriptions before any request can emit an event. */
2
+ export async function prepareLocalDatabase({migrate,register}) {
3
+ if(!await migrate())return 'Local database migration failed; fix the D1 error above before serving requests.';
4
+ if(!await register())return 'Local function registration failed; fix the D1 error above before serving requests.';
5
+ return null;
6
+ }
package/lib/mcp-tools.mjs CHANGED
@@ -843,7 +843,7 @@ export function toolDefs(ctx, avail = {}) {
843
843
  let at = dir;
844
844
  if (existsSync(at) && readdirSync(at).some((f) => !['.DS_Store', '.git'].includes(f)) && !existsSync(join(at, 'studio.json'))) at = `${dir}-studio`;
845
845
  let made;
846
- try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false, ...(ctx.directory ? { homie: ctx.directory } : {}) }); } catch (error) { return fail(error.message); }
846
+ try { made = newStudio(at, { name, kind: a.kind ?? 'game', install: false }); } catch (error) { return fail(error.message); }
847
847
  ctx.current = made.dir;
848
848
  ctx.note(made.dir).earlier = earlier.map((e) => e.path);
849
849
  let install = null;
@@ -1258,10 +1258,10 @@ export function toolDefs(ctx, avail = {}) {
1258
1258
  // the directory's read-only count for this site, the parts' lines, and nothing published. Run here, by THIS
1259
1259
  // toolkit, never handed to the studio's pinned one: a 0.31.0 toolkit reads `publish --before` as `publish`.
1260
1260
  if (a.before === true) {
1261
- const b = await publishBefore(root, { site: a.site ? String(a.site) : undefined });
1261
+ const b = await publishBefore(root, { homie: ctx.directory ?? undefined, site: a.site ? String(a.site) : undefined });
1262
1262
  return ok(b.lines.join('\n'), { kind: 'publish-count', ...publishesSoFar(root), ...b });
1263
1263
  }
1264
- const r = await cli(ctx, root, 'publish', ['publish', ...(a.site ? ['--site', String(a.site)] : [])]);
1264
+ const r = await cli(ctx, root, 'publish', ['publish', ...(ctx.directory ? ['--homie', ctx.directory] : []), ...(a.site ? ['--site', String(a.site)] : [])]);
1265
1265
  if (!r.ended) return stillRunning(r.job, 'Listing');
1266
1266
  if (r.job.code !== 0) return fail(`Not listed: ${whyOf(r.job)}`, r.result?.publishes ? { kind: 'publish', ok: false, needs: r.result.needs ?? null, publishes: r.result.publishes } : undefined);
1267
1267
  return ok(`Listed: ${r.result?.studioPage ?? r.result?.directory ?? ''}\n${(r.result?.games ?? []).map((g) => ` ${g.name}: ${g.play}`).join('\n')}${r.result?.publishes?.line ? `\n${r.result.publishes.line}` : ''}${partsSaid.length ? `\n${partsSaid.join('\n')}` : ''}`, { kind: 'publish', ...r.result });
@@ -628,12 +628,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
628
628
  }
629
629
  }
630
630
 
631
- /* ---- two fresh browsers meet through the lobby and finish a round */
631
+ /* ---- a public join and an invited browser finish a round */
632
632
  if (want.has('round') && await gate('round')) {
633
633
  try {
634
634
  const A = await launch('desk');
635
635
  const B = await launch('phone');
636
- const [oa, ob] = [await open(A, `${base}/${game}/play?name=Desk`), await open(B, `${base}/${game}/play?name=Phone`)];
636
+ const oa = await open(A, `${base}/${game}/play?name=Desk`);
637
+ const ob = await open(B, `${base}/${game}/play?name=Phone${oa.shell?.room ? `&room=${encodeURIComponent(oa.shell.room)}` : ''}`);
637
638
  if (!oa.ok || !ob.ok) row('round', false, { why: `a browser was never seated (${!oa.ok ? `computer: ${oa.why}` : ''} ${!ob.ok ? `phone: ${ob.why}` : ''})` });
638
639
  else if (oa.shell.room !== ob.shell.room) row('round', false, { why: `the two browsers landed in different rooms (${oa.shell.room}, ${ob.shell.room})` });
639
640
  else {
package/lib/scaffold.mjs CHANGED
@@ -152,6 +152,8 @@ the Table/Lobby Durable Objects, on the studio's Cloudflare account), built
152
152
  from \`@homie-rocks/studio\`, pinned in \`package.json\`. The homie.rocks
153
153
  directory lists its games; homie.rocks does not host them.
154
154
 
155
+ For studio-owned HTTP endpoints, read \`node_modules/@homie-rocks/studio/docs/custom-routes.md\` before editing \`site/src/worker.mjs\`. Use the reserved \`/api/custom/*\` namespace and the documented authorization and cookie scopes.
156
+
155
157
  ## Version-matched authoring guides
156
158
 
157
159
  Before authoring or rewriting a game, read \`node_modules/@homie-rocks/studio/guides/game/SKILL.md\`,
@@ -4020,7 +4020,52 @@
4020
4020
  },
4021
4021
  "sections": {
4022
4022
  "AGENTS.md": {
4023
- "(intro)": "394cbbe934e80722",
4023
+ "(intro)": [
4024
+ "394cbbe934e80722",
4025
+ "af7f2f732505d379"
4026
+ ],
4027
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4028
+ "## Layout": "f3a280b8c96cd068",
4029
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4030
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4031
+ "## The site": "0cb8dd9a6f8a0096",
4032
+ "## Making games": "bb715272fd1a615b",
4033
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4034
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4035
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4036
+ "## Servers and AI seats": "1ccfd549d2369a60",
4037
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4038
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4039
+ "## Rules": "b2234d80f6cbe71d",
4040
+ "## Telling Homie": "80b46fdd9e578db8",
4041
+ "## Beta": "9d02638207a26e73"
4042
+ }
4043
+ },
4044
+ "named": {
4045
+ "Homie Arcade": {
4046
+ "sections": {
4047
+ "AGENTS.md": {
4048
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4049
+ }
4050
+ }
4051
+ }
4052
+ }
4053
+ },
4054
+ "0.56.0": {
4055
+ "files": {
4056
+ "CLAUDE.md": "ddf2b2a0376202f6",
4057
+ "README.md": "684b3cca8001673c",
4058
+ "HANDOFF.md": "29337e2ace95b2e7",
4059
+ "games/README.md": "5ad32fe80c61f117",
4060
+ "music/README.md": "a3dc042dadd39df8",
4061
+ "videos/README.md": "16babc3437d5545b",
4062
+ "posts/README.md": "37841aae476bd540",
4063
+ "site/README.md": "1dbdf22c03a1e505",
4064
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4065
+ },
4066
+ "sections": {
4067
+ "AGENTS.md": {
4068
+ "(intro)": "af7f2f732505d379",
4024
4069
  "## Version-matched authoring guides": "f6261a0e5801b96b",
4025
4070
  "## Layout": "f3a280b8c96cd068",
4026
4071
  "## Apps: one screen, roles and parts": "c10aa0441ea89702",
@@ -20,7 +20,10 @@ export async function buildWorker(root,esbuild,metadata=null) {
20
20
  // classes in the generated bundle without rewriting the studio's source/customizations.
21
21
  const standardRooms=/export\s*\{[^}]*\bTable\b[^}]*\bLobby\b[^}]*\}\s*from\s*['"]@homie-rocks\/studio\/worker['"]/.test(readFileSync(source,'utf8'));
22
22
  const lazyRooms=standardRooms?`export {Table,Lobby} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`:'';
23
- const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
23
+ // A custom fetch wrapper may predate background delivery. Complete the event
24
+ // boundary here, preserving explicit studio handlers and leaving source alone.
25
+ const toolkitEntry=alias['@homie-rocks/studio/worker']==='@homie-rocks/studio/worker/selling'?'worker/selling/index.mjs':'worker/index.mjs';
26
+ const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nimport studioWorker from ${JSON.stringify(source)};\nimport toolkitWorker from ${JSON.stringify(join(PACKAGE_ROOT,toolkitEntry))};\nexport default { ...studioWorker, queue: studioWorker.queue ?? toolkitWorker.queue, scheduled: studioWorker.scheduled ?? toolkitWorker.scheduled };\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
24
27
  const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent',plugins:[{name:'studio-runtime',setup(build){
25
28
  if(metadata)build.onLoad({filter:/[/\\]worker[/\\]built-metadata\.mjs$/},()=>({contents:'export default '+JSON.stringify(metadata),loader:'js'}));
26
29
  // Merely overriding entry exports still evaluates the barrel's dependencies.
@@ -28,6 +28,13 @@ export function updateWorkerConfig(root,generated) {
28
28
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
29
29
  }
30
30
  const next=merge(base,custom);
31
+ // Authorization paths are additive: custom assets must never bypass toolkit gates.
32
+ const requiredRoutes=base.assets?.run_worker_first, customRoutes=custom.assets?.run_worker_first;
33
+ // A "!" pattern excludes paths from the Worker. Only Homie writes those (static-assets.mjs): a custom one
34
+ // could take /games/*, /_studio/* or an app's files out from behind their access checks.
35
+ const exclusions=Array.isArray(customRoutes)?customRoutes.filter(p=>String(p).startsWith('!')):[];
36
+ if(exclusions.length)throw new Error(`wrangler.custom.json assets.run_worker_first cannot exclude paths (${exclusions.join(', ')}): list only the extra paths your own routes need`);
37
+ next.assets={...next.assets,run_worker_first:requiredRoutes===true || customRoutes===true ? true : [...new Set([...(Array.isArray(requiredRoutes)?requiredRoutes:[]),...(Array.isArray(customRoutes)?customRoutes:[])])]};
31
38
  if(readStudio(root).concept===true)next.assets={...next.assets,run_worker_first:true};
32
39
  next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
33
40
  verifiedMcpConfig(root);
@@ -1591,7 +1591,11 @@ A page stands in one of six places with its room, and the helper says which:
1591
1591
  - **`net.offline` means "not in a room"**, not "no network": it is true for `alone` and `offline`.
1592
1592
  **`net.connected`** is true only while the socket is up and welcomed. The state between them
1593
1593
  (`!net.offline && !net.connected`) is `reconnecting`.
1594
- - **`on('link', (e) => …)`** fires on every change with `{ state, prev, why, hosting, downMs }`.
1594
+ - **`on('link', (e) => …)`** immediately calls the listener with the current state
1595
+ (`prev === state`, `why: 'subscribe'`), then fires on every change with `{ state, prev, why, hosting, downMs }`.
1596
+ Ignore `why === 'subscribe'` when triggering reconnect recovery; it initializes
1597
+ UI and does not signal a new connection. Listener errors are isolated, including
1598
+ this immediate call, and `on()` always returns the unsubscribe function.
1595
1599
  `hosting` says this page is running the rules right now. Two pages can both say so for a
1596
1600
  moment: a host that was cut off keeps simulating until its welcome tells it the room moved on,
1597
1601
  and a page that is `alone` hosts its own private round. Before revision 9 neither said