@homie-rocks/studio 0.55.0 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
package/CHANGELOG.md CHANGED
@@ -17,6 +17,78 @@ 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
+ ## [Unreleased]
21
+
22
+ ### Fixed
23
+
24
+ - The MCP Events workerd conformance test now parses SSE frames across HTTP chunks, including combined event/heartbeat frames and split UTF-8. Its former one-chunk-per-event assumption caused the intermittent 0.57.0 publish-gate JSON parse failure; production delivery and webhook formats are unchanged. PR CI adds a path-filtered Events/webhook gate covering protocol, push, durable delivery and signed studio events.
25
+
26
+ ## [0.57.0] - 2026-10-16
27
+
28
+ **Plugin 0.57.0**
29
+
30
+ Studios can book at each other on behalf of a person, with proven studio identity and signed events, and friends joining a browser-hosted game at the same moment always get their seat.
31
+
32
+ ### Fixed
33
+
34
+ - Browser-hosted games retry connected players’ seat claims when the host’s roster has no player body for them. Friends joining while the host is still loading now become seated players when its world is ready, including games with older bundled netplay clients. Fixes the 0.56.0 regression where Kart Royale replicas stayed bots across rounds; the two-browser check retains its original early-join timing. Host-driven AI seats count as acknowledged; missing-seat retries use capped exponential backoff and stop after eight attempts with one diagnostic, avoiding repeated joins when a game has no available body.
35
+ - Studio ingress now limits requests by IP before discovery; failed JWKS fetches and unknown keys share an atomic 60-second per-origin fetch bound. Authentication errors are generic, with stage details only in owner audit logs. Signed events use the exact inbound path rule, honour disabled entries and reject non-object bodies with 400. Removed the unused v1-only webhook production dependency and documented exact manual key rotation steps through stdin.
36
+
37
+ - Studio-to-studio limits aggregate by proven origin, outbound destinations require explicit configuration, and remote results retain untrusted provenance. Asserted handles never identify local accounts.
38
+ - Availability allocates from a page snapshot in three queries instead of querying per slot, option and resource.
39
+ - Studio identity uses Ed25519 private-key JWT and origin-hosted JWKS; events use Standard Webhooks v1a with the same key. Deploy provisions private keys through stdin. Office Allow/Deny is stored in D1, denied by default, with origin/handle audit entries. Owners exchange no secrets.
40
+ - Studio events and business feed data share an 8,192-byte limit. Discovery and tokens are cached within each request; proof scripts use deploy URLs without reading Wrangler OAuth files.
41
+
42
+ ### Added
43
+
44
+ - Studio-to-studio tools use the official MCP SDK with bounded HTTPS transport, receiver-owned OAuth client credentials and a verified studio caller distinct from an asserted end-user handle.
45
+ - Booking availability returns paged schema.org offers and exact booking arguments, shared by AI tools and the existing booking choices UI.
46
+ - Signed cross-studio events reuse Standard Webhooks, per-sender allowlists and durable business-event receipts to trigger studio functions.
47
+ - Tools can call their own custom routes with the caller's identity and share opt-in Cloudflare Rate Limiting bindings.
48
+
49
+ ### Upgrade notes
50
+
51
+ - Studios that call or accept calls from other studios list them in studio.json (outbound) and allow them in the office (inbound); the deploy provisions each studio's signing key — nothing is exchanged between owners. Existing OAuth browser connections, booking migrations and function delivery remain in use. No version bump or new migration. See `packages/studio/docs/studio-to-studio.md`.
52
+
53
+ ## [0.56.0] - 2026-10-15
54
+
55
+ **Plugin 0.56.0**
56
+
57
+ Studios scale with hibernating rooms, add custom routes with built-in access checks, and deploy reliably with background queue handlers.
58
+
59
+ ### Added
60
+
61
+ - 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.
62
+ - Public `isOwner(request, env)` and `hasRole(request, env, app, role)` authorization helpers using existing live session and app access checks.
63
+ - Secret capabilities may use `self` for the runtime studio origin, with origin and redirect restrictions retained.
64
+
65
+ ### Changed
66
+
67
+ - 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.
68
+ - 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.
69
+ - 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.
70
+ - 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.
71
+ - 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.
72
+
73
+ ### Fixed
74
+
75
+ - 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.
76
+ - Deploy failures retain Wrangler's real error and context with secrets redacted, instead of showing only the last six lines.
77
+ - 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.
78
+ - Custom `assets.run_worker_first` entries cannot exclude paths with `!`; the build refuses them so private app files always stay behind their access checks.
79
+ - Late link subscriptions no longer trigger rules-view reconnect recovery; throwing listeners still return their unsubscribe function.
80
+ - 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.
81
+ - Scaffold, local database and login regression tests use OS temporary directories and work from the package test command.
82
+ - New studios leave directory listing opt-in; existing explicit directory choices are preserved.
83
+ - 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.
84
+ - Room-link listeners immediately receive current state, so wall UIs mounted after welcome do not stay on “Connecting to Homie.”
85
+ - Local dev refuses to serve until migrations and function registration succeed, preserving first-notice delivery on a fresh database.
86
+
87
+ ### Upgrade notes
88
+
89
+ - `net.on('link', listener)` now calls the listener synchronously on subscription with `why: 'subscribe'` and `prev === state`, then reports changes as before.
90
+ - Custom routes and authorization examples are in `packages/studio/docs/custom-routes.md` (the installed package uses `docs/custom-routes.md`).
91
+
20
92
  ## [0.55.0] - 2026-10-15
21
93
 
22
94
  **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
 
package/apps/APPS.md CHANGED
@@ -339,3 +339,12 @@ compiler; run `npm install` in the studio first if it is missing. Review the dif
339
339
  combined timer/jobs, hand-written routes and business-event mirrors need a semantic
340
340
  migration to the APIs above; they are not automatically deleted. The owner's
341
341
  words, historical evidence and unresolved integration work remain intact.
342
+
343
+ ## Availability shared with an AI
344
+
345
+ `studio_availability` and `context.bookings.availability` return paged offers with
346
+ valid booking arguments, checking the existing slots, holds, options and resources.
347
+ Pass that result to `createBookingChoices({container,availability})`; `value()`
348
+ returns the selected offer's booking arguments for the same booking tool an AI
349
+ uses. Availability is a snapshot; authoritative holds still recheck capacity.
350
+ See [the convention](../docs/studio-to-studio.md#availability-convention-v).
package/apps/apps.ts CHANGED
@@ -78,7 +78,25 @@ export function createAppContext() {
78
78
  }
79
79
 
80
80
  /** Native accessible choices; submit value() to the same booking tool used by an AI. */
81
- export function createBookingChoices({container,options=[],resources=[]}:{container:HTMLElement;options?:Array<{id:string;name:string}>;resources?:Array<{id:string;name:string}>}) {
81
+ export interface BookingAvailability {
82
+ convention: 'studio.booking-availability/1';
83
+ bookingTool: string;
84
+ items: Array<{identifier: string; name?: string; availabilityStarts?: string; booking: {app: string; collection: string; slot: string; quantity: number; option?: string; resource?: string; addons?: Record<string,number>}}>;
85
+ next: string|null;
86
+ }
87
+ export function createBookingChoices({container,options=[],resources=[],availability}:{container:HTMLElement;options?:Array<{id:string;name:string}>;resources?:Array<{id:string;name:string}>;availability?:BookingAvailability}) {
88
+ if(availability){
89
+ if(availability.convention!=='studio.booking-availability/1')throw new Error('Unsupported booking availability convention');
90
+ const label=document.createElement('label');label.textContent='Available bookings';
91
+ const select=document.createElement('select');select.name='booking';
92
+ const empty=document.createElement('option');empty.value='';empty.textContent='Choose a booking';select.append(empty);
93
+ const bookings=availability.items.map(item=>structuredClone(item.booking));
94
+ for(const [index,item] of availability.items.entries()){
95
+ const option=document.createElement('option');option.value=String(index);option.textContent=[item.name??item.booking.slot,item.availabilityStarts].filter(Boolean).join(' · ');select.append(option);
96
+ }
97
+ label.append(select);container.append(label);
98
+ return {value:():Partial<BookingAvailability['items'][number]['booking']>=>select.value===''?{}:structuredClone(bookings[Number(select.value)]),dispose:()=>label.remove()};
99
+ }
82
100
  const fields=new Map<string,HTMLSelectElement>();
83
101
  const group=document.createElement('fieldset');
84
102
  const legend=document.createElement('legend');legend.textContent='Choose an option';group.append(legend);
@@ -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.
@@ -0,0 +1,248 @@
1
+ # One studio acting at another
2
+
3
+ A concierge can discover a venue, select a visit, reserve it for a person and tell
4
+ its host when they will arrive. Each studio runs its own tools, OAuth, database and
5
+ functions. The same mechanisms apply to a resort stay or any other collaboration.
6
+
7
+ ## Client (t)
8
+
9
+ Tools and functions receive `context.studios`:
10
+
11
+ ```ts
12
+ const origin = 'https://venue.example';
13
+ const page = await ctx.studios.listTools(origin); // pass {cursor: page.nextCursor}
14
+ const available = await ctx.studios.call(origin, 'studio_availability', {
15
+ app: 'venue', collection: 'bookings'
16
+ });
17
+ if (available.isError) throw new Error('Availability could not be read');
18
+ // Inspect available.data.items, choose an offer, then:
19
+ const booked = await ctx.studios.call(origin, 'studio_book', {
20
+ ...chosenOffer.booking, id: stableBookingId, expiresAt: Date.now() + 600000
21
+ }, {assertedHandle: 'guest-42'});
22
+ if (booked.isError) throw new Error('Reservation was refused');
23
+ ```
24
+
25
+ The official MCP TypeScript client owns initialization, Streamable HTTP/SSE,
26
+ OAuth discovery, client authentication, schema validation and protocol errors.
27
+ `call` returns `{source:{origin,authenticated},untrusted:true,isError,data?,content}`. Structured content is preserved; the
28
+ kit's legacy `untrusted-tool-data` envelope is decoded while retaining the untrusted marker and provenance. `listTools` and each description are also labelled untrusted. Before any `ctx.ai` call, wrap remote data and descriptions as untrusted input, preserving `source` and `untrusted`; never concatenate them into system instructions. Content and
29
+ `data` remain untrusted business data, never instructions or verified claims.
30
+ Transport failures reject; tool failures return `isError:true`. No automatic
31
+ booking or payment retries are introduced. Use stable business IDs for retries.
32
+
33
+ Each operation has a ten-second deadline including discovery, a 1 MiB response
34
+ limit before SDK parsing, and a 64 KiB argument limit. Origins must be bare HTTPS
35
+ origins on port 443 with public DNS names. IP literals (including IPv6, mapped and integer forms), localhost, .localhost, .local and .internal are refused unless that exact outbound entry explicitly sets `allowPrivate:true`. Config keys are normalized; duplicate normalized keys or mismatched `origin` fields fail. Never take an origin from model or user input without checking it against a list of permitted origins. All redirects are refused. Every fetch, including OAuth metadata and token
36
+ requests, must stay on the chosen origin. Credentials are issuer-pinned. A client
37
+ and its transport close after each operation; discovery and tokens are cached per origin within the request; tool listing is cursor-paged.
38
+ Keep the toolkit's `global_fetch_strictly_public` compatibility flag for
39
+ Worker-to-Worker HTTP calls. Every destination must be configured and delegated to the calling tool. Public anonymous calls require `anonymous:true` on that exact outbound entry. An unknown origin never falls back to anonymous. Studio code is trusted Worker
40
+ code: these safeguards are not a sandbox for imported JavaScript. Choose trusted
41
+ remote operators; this does not implement DNS address pinning.
42
+
43
+ ## Studio identity and allowlists (u)
44
+
45
+ Each studio deploy generates its own Ed25519 key into the `STUDIO_SIGNING_KEY`
46
+ Worker secret via stdin. Private keys never appear in model output, source or
47
+ studio.json. Existing keys survive redeploys. Owners exchange nothing.
48
+ The caller publishes only public keys at `https://<origin>/.well-known/jwks.json`.
49
+ The receiver fetches that exact origin with bounded responses and no redirects.
50
+
51
+ B declares permitted capabilities:
52
+
53
+ ```json
54
+ {"mcp":{"studios":{"inbound":{"concierge":{
55
+ "origin":"https://concierge.example",
56
+ "tools":["studio_availability","studio_book"]
57
+ }}}}}
58
+ ```
59
+
60
+ A delegates the outbound destination:
61
+
62
+ ```json
63
+ {"mcp":{"studios":{"outbound":{"https://venue.example":{
64
+ "tools":["plan_drive"]
65
+ }}}}}
66
+ ```
67
+
68
+ B's owner opens **Office → AI connections** and clicks **Allow** for the
69
+ configured calling origin. All start denied. Allow/Deny is stored in D1 and takes
70
+ effect on every dispatch without redeploying, including existing tokens. Tool and
71
+ event capability declarations remain in configuration. Audit entries show the
72
+ calling origin and the asserted handle, not a misleading local account name.
73
+
74
+ `/oauth/token` accepts `client_credentials` with RFC 7523 `private_key_jwt`,
75
+ `scope=studio`, and the receiver's `/mcp` resource. The client ID, issuer and subject
76
+ are the caller's canonical origin; audience is the exact receiver token endpoint.
77
+ Assertions have short expiry, `kid`, and a one-use `jti` checked atomically in D1.
78
+ The receiver accepts Ed25519/EdDSA keys. Five-minute opaque access tokens are hashed
79
+ in D1. Browser OAuth continues using the existing provider.
80
+
81
+ JWKS cache lifetime is five minutes. Failed fetches are cached for 60 seconds;
82
+ unknown `kid` refreshes are limited to once per origin per 60 seconds, including
83
+ concurrent requests. Token and studio-hook requests share a mandatory limit of
84
+ 60 requests per IP per minute before key discovery. Throttled requests return
85
+ 429 with `Retry-After: 60`. Authentication failures return the same generic 401;
86
+ only the owner-visible audit log records the failed stage.
87
+
88
+ ### Manual key rotation
89
+
90
+ Rotation is manual today. From the studio's `site/` directory (the directory with
91
+ its deployed `wrangler.jsonc`), run the following with the deployed origin in
92
+ `STUDIO_ORIGIN`. These commands fetch only public keys; the new private key stays
93
+ in the Node process and Wrangler stdin. Do not enable shell tracing or print the
94
+ bulk input. Use the same Wrangler environment/account as the existing deployment.
95
+
96
+ ```sh
97
+ export STUDIO_ORIGIN=https://your-studio.example
98
+ node --input-type=module <<'NODE' | npx wrangler secret bulk
99
+ import {generateKeyPairSync, randomUUID} from 'node:crypto';
100
+ const response = await fetch(new URL('/.well-known/jwks.json', process.env.STUDIO_ORIGIN), {redirect:'error'});
101
+ if (!response.ok) throw new Error('Cannot read existing public keys; rotation aborted');
102
+ const jwks = await response.json();
103
+ if (!Array.isArray(jwks.keys) || !jwks.keys.length || jwks.keys.length > 7 ||
104
+ jwks.keys.some(k => !k || k.d || k.kty !== 'OKP' || k.crv !== 'Ed25519' || !k.x || !k.kid))
105
+ throw new Error('Invalid or too many retained public keys; rotation aborted');
106
+ const previous = jwks.keys.map(({kty,crv,x,kid}) => ({kty,crv,x,kid,alg:'EdDSA',use:'sig'}));
107
+ const {privateKey} = generateKeyPairSync('ed25519');
108
+ const next = {...privateKey.export({format:'jwk'}), kid:randomUUID()};
109
+ process.stdout.write(JSON.stringify({
110
+ STUDIO_PREVIOUS_PUBLIC_KEYS: JSON.stringify(previous),
111
+ STUDIO_SIGNING_KEY: JSON.stringify(next)
112
+ }));
113
+ NODE
114
+ ```
115
+
116
+ 1. Confirm Wrangler reports that **both** secrets were updated. If the command
117
+ fails, inspect the public JWKS and Wrangler deployment status before retrying.
118
+ 2. Check `curl --fail "$STUDIO_ORIGIN/.well-known/jwks.json"`: it must publish the
119
+ new `kid` and the previous public keys, with no `d` field. Peer caches may take
120
+ up to 60 seconds to refresh for the new `kid`; retry failed operations with
121
+ their original stable business/event IDs.
122
+ 3. Retain the old public keys for at least ten minutes after successful rotation.
123
+ Then, provided no other rotation is still in its overlap period, remove them:
124
+ `printf '%s' '[]' | npx wrangler secret put STUDIO_PREVIOUS_PUBLIC_KEYS`.
125
+ 4. Check the public JWKS again: only the new public key should remain. Existing
126
+ signing keys survive future normal deploys. Deny the origin immediately if a
127
+ key is compromised; rotation overlap is not emergency revocation.
128
+
129
+ ```ts
130
+ ctx.caller.studio // {origin:'https://concierge.example', verified:true,
131
+ // method:'private_key_jwt'}
132
+ ctx.caller.actingFor // {assertedHandle:'guest-42', verified:false,
133
+ // assertedBy:'https://concierge.example'}
134
+ ```
135
+
136
+ Verified means the caller proved possession of a key published by that HTTPS
137
+ origin. It does not verify any person. `assertedHandle` permits only
138
+ `[A-Za-z0-9._@-]`, 1–128 characters. Never match it to a local account or grant
139
+ roles from it. It only separates bookings within the calling studio. A studio can
140
+ assert any handle within its own origin; all handles share origin-level rate
141
+ limits. Studio callers use only allowlisted public tools, never signed-in roles.
142
+
143
+ References: [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) and the
144
+ [MCP client credentials extension](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx).
145
+
146
+ ## Availability convention (v)
147
+
148
+ `studio_availability({app,collection,after?,quantity?,option?,resource?,addons?})`
149
+ and `ctx.bookings.availability(...)` return:
150
+
151
+ ```json
152
+ {"convention":"studio.booking-availability/1","bookingTool":"studio_book",
153
+ "items":[{"@type":"Offer","identifier":"[\"morning\",null]",
154
+ "availability":"https://schema.org/InStock","remaining":2,
155
+ "availabilityStarts":"2030-10-15T10:00:00Z",
156
+ "availabilityEnds":"2030-10-15T11:00:00Z",
157
+ "booking":{"app":"venue","collection":"bookings","slot":"morning","quantity":1}}],
158
+ "next":null}
159
+ ```
160
+
161
+ The snapshot loads a page of slots, resource records and active allocations in three queries regardless of slot × option count (plus access-policy checks), and uses the same
162
+ resource/option allocator as `studio_book`. It checks app visibility and slot-read
163
+ grants. A page scans at most 50 slots and supports at most 64 alternatives per
164
+ slot; `next` can be non-null even when no offers on this page are available.
165
+ Only feasible options are returned. Add-ons and preferred resources can be checked
166
+ as query inputs. No booking is taken during discovery. Holds still enforce capacity
167
+ atomically; concurrent clients may see the same offer and only one may book.
168
+ Expired holds continue occupying capacity until the existing expiry function
169
+ releases them, matching the authoritative hold path.
170
+
171
+ The existing `createBookingChoices({container,availability})` UI part accepts this
172
+ same result and returns the selected offer's exact booking arguments from `value()`.
173
+ Its existing explicit `options`/`resources` mode remains available. Share custom
174
+ booking parts with an availability tool returning this convention and name their
175
+ booking tool in `bookingTool`.
176
+
177
+ [Schema.org Offer](https://schema.org/Offer) supplies `availability`, `identifier`
178
+ and availability dates. A successful hold corresponds to a
179
+ [Reservation](https://schema.org/Reservation): its `id` maps to `reservationId`,
180
+ `state` to reservation status, and the chosen slot to `reservationFor`. The
181
+ kit keeps its existing versioned hold response and payment lifecycle. Local
182
+ `booking`, `remaining`, pagination and convention fields are explicit extensions;
183
+ this is not a claim that schema.org defines a booking API.
184
+
185
+ The [Agentic Commerce Protocol](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol)
186
+ standardizes commerce/checkout interactions. Its separation of available offers
187
+ from authoritative checkout is compatible with this convention, but it does not
188
+ supply this toolkit's time-slot and multi-resource allocation schema. Availability
189
+ does not invent checkout, represent a payment approval, or claim ACP conformance.
190
+
191
+ ## Signed arrival events (o)
192
+
193
+ Use Standard Webhooks asymmetric `v1a` Ed25519 signatures with the same studio key and the envelope `{eventId,name,timestamp,source,data}`. On A's outbound destination, add:
194
+
195
+ ```json
196
+ {"eventPath":"/hooks/studios/concierge","events":["arrival"]}
197
+ ```
198
+
199
+ On B's inbound `concierge` entry, add
200
+ `"events":{"arrival":"arrivals"}`. No shared event secret is needed. B's `mcp.events.arrivals` declares `event:"guest.arriving"`, an audience,
201
+ and a schema for `{sender:{origin,verified:true},data:{booking,minutes}}`.
202
+ Only mapped names can publish to local feeds. Define a function with
203
+ `event:'guest.arriving'` to consume `event.data.data` and its verified
204
+ `event.data.sender`. The sender cannot select an arbitrary local event type.
205
+
206
+ ```ts
207
+ await ctx.studios.send('https://venue.example', 'arrival', {
208
+ id: stableArrivalId, data: {booking: bookingId, minutes: 10}
209
+ });
210
+ ```
211
+
212
+ The JavaScript `standardwebhooks` package currently supports HMAC `v1` only, so
213
+ studio `v1a` uses Web Crypto directly, with fixed cryptographic and spec-framing
214
+ regression vectors. The receiver verifies Standard Webhooks `id.timestamp.body` signatures with Web Crypto Ed25519, including a five-minute timestamp window. Both studio event envelopes and business-feed data are limited to **8,192 UTF-8 bytes**; oversized events return a clear 413 error. `webhook-key-id` selects the origin JWKS key; `webhook-origin` must match the configured sender and signed body. IDs are 1–128 letters,
215
+ digits, underscores or hyphens. A receipt is committed atomically with the event
216
+ using existing business-event publication. Identical retries return 204 without
217
+ running the function again; a signed changed payload under the same ID returns
218
+ 409. Receipts survive event pruning. Function execution remains at least once:
219
+ handlers must deduplicate external effects by event ID. If acceptance fails, the
220
+ sender gets an error and can retry the same ID. Calls to `send` do not create an
221
+ outbound retry queue; call from an existing durable function for automatic retries.
222
+ The existing configured-feed/MCP Events subscription dispatcher remains available
223
+ for subscription delivery. No new event bus or MCP method is introduced.
224
+
225
+ ## Friend routes and rate limits (p, q)
226
+
227
+ `await ctx.fetchSelf('/api/friend-action', {method:'POST', body:...})` invokes the
228
+ studio's registered `useRoutes` handler in-process. It carries the caller through
229
+ the existing internal identity mechanism. `isOwner`, `hasRole`, and the studio's
230
+ player lookup still perform their normal checks. Explicit cookies/Authorization
231
+ headers, URL credentials and other origins are refused. A missing custom route
232
+ returns 404. `fetchSelf` sets `Origin` to the local studio origin, so a custom route’s same-origin/CSRF check will pass; that check is not authorization. Routes must still check the propagated identity and roles. It does not borrow `fetchWithSecret` or mint an owner credential.
233
+
234
+ `await ctx.rateLimit('friends')` uses a declared Cloudflare Rate Limiting binding,
235
+ sharing the same namespace and account key between delegated tools:
236
+
237
+ ```json
238
+ {"mcp":{"rateLimits":{"friends":{"binding":"FRIEND_RATE","tools":["join","reserve"]}}}}
239
+ ```
240
+
241
+ In wrangler.jsonc, configure `ratelimits` with a stable `namespace_id`, name
242
+ `FRIEND_RATE` and `simple:{limit:30,period:60}`. The helper returns `{success}`;
243
+ the tool chooses its refusal response. No policy is enabled by default. Signed-in
244
+ callers default to their identity key; studio callers default to `studio:<origin>` regardless of asserted handle. Public MCP callers expose `ctx.caller.clientAddressKey` and use that address-based key by default. Do not let customers select keys to bypass your policy.
245
+ [Cloudflare's binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
246
+ is an approximate per-location limiter, not a globally strict quota or booking
247
+ capacity mechanism. Existing authoritative booking/database checks remain necessary.
248
+
@@ -424,3 +424,12 @@ or declared webhook; functions never send email/SMS themselves.
424
424
  Apply `0028_business_events.sql` on upgrade. Local proof publications carry their
425
425
  scope and do not appear in production feed reads. Event feeds retain their declared
426
426
  type; give different business feeds distinct types when their content differs.
427
+
428
+ ## Events from another studio
429
+
430
+ An explicitly allowed studio can send a Standard Webhooks arrival event to
431
+ `/hooks/studios/<connection>`. The receiver maps allowed names to declared business
432
+ feeds and commits their existing durable receipt before acknowledging. Functions
433
+ receive the verified sender origin separately from the sender's untrusted data.
434
+ Use `context.studios.send` inside a durable function when sending needs retries.
435
+ See [studio-to-studio](../docs/studio-to-studio.md) for setup and replay semantics.
@@ -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
  }