@homie-rocks/studio 0.51.6 → 0.51.8

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 (42) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/apps/APPS.md +26 -0
  3. package/bin/homie-studio.mjs +10 -5
  4. package/docs/toolkit-place-patterns.md +62 -0
  5. package/functions/FUNCTIONS.md +2 -2
  6. package/guides/app/SKILL.md +34 -0
  7. package/guides/app/examples/reserve.mjs +13 -0
  8. package/guides/functions/SKILL.md +2 -0
  9. package/guides/office/SKILL.md +27 -1
  10. package/guides/servers/SKILL.md +27 -1
  11. package/guides/shop/SKILL.md +38 -15
  12. package/guides/studio-setup/SKILL.md +4 -0
  13. package/lib/app-needs.mjs +8 -2
  14. package/lib/app-ready.mjs +31 -0
  15. package/lib/check.mjs +9 -6
  16. package/lib/cloudflare.mjs +4 -4
  17. package/lib/connector-token.mjs +30 -0
  18. package/lib/mcp-tools.mjs +1 -1
  19. package/lib/shop-links.mjs +70 -32
  20. package/lib/shop-mcp.mjs +37 -10
  21. package/lib/stripe-connect.mjs +4 -4
  22. package/lib/template-history.json +84 -0
  23. package/lib/worker-config.mjs +3 -0
  24. package/netplay/netplay.ts +3 -0
  25. package/package.json +1 -1
  26. package/rules/collision.ts +4 -2
  27. package/rules/terrain.ts +2 -1
  28. package/rules/tiles.ts +8 -4
  29. package/shop/SHOP.md +38 -15
  30. package/worker/app-graphics.mjs +8 -0
  31. package/worker/app-shell.mjs +36 -15
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/connector-token.mjs +11 -0
  34. package/worker/index.mjs +5 -2
  35. package/worker/join-area.mjs +35 -0
  36. package/worker/mcp-tools.mjs +12 -3
  37. package/worker/mcp.mjs +14 -6
  38. package/worker/pages.mjs +6 -3
  39. package/worker/records-page.mjs +3 -2
  40. package/worker/records-prefs.mjs +24 -0
  41. package/worker/shop.mjs +2 -2
  42. package/worker/version.mjs +1 -1
package/shop/SHOP.md CHANGED
@@ -115,20 +115,30 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
115
115
  read `/v1/account` to record its account ID. Live mode only after the owner requests it.
116
116
  2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
117
117
  `observations: []`. CLI equivalent: `homie-studio shop plan --have plan-input.json`.
118
- That JSON file contains the same fields. A returned `next` is the exact operation:
119
- method, API path, parameters, API version and (for creates) idempotency key. Updates set desired fields; read before retrying. Metadata
120
- scopes objects to this studio and origin; prices and links have content revisions.
121
- 3. Perform each `next` through Stripe's official MCP. For GET, append
122
- `{ "path": "…", "params": {…}, "body": <Stripe response> }` to `observations`
123
- and plan again. Follow every pagination request. After a write, discard observations
124
- and read fresh. Replanning resolves real Stripe IDs; never invent IDs or substitute
125
- a different price. Reuse the supplied idempotency key where the tool supports it;
126
- otherwise read before retrying and match the stable product ID/revision metadata.
127
- 4. When no writes remain, use fresh reads with `stripe_shop_plan` `verify: true`
128
- (CLI `homie-studio shop verify --have plan-input.json`). It checks the catalog,
129
- actual link fields and line items, menus, slot limits and portal settings against
130
- shop.json and the slot manifest. A mismatch blocks verification. It saves only public
131
- configuration to `.studio/stripe-mcp.json`; local test configuration goes to `.dev.vars`.
118
+ That JSON file contains the same fields. `operations` contains every independent
119
+ operation currently ready, with method, path, parameters and create idempotency key.
120
+ Execute the batch through Stripe's official plugin, then replan for dependent work.
121
+ `next` remains the first operation for older clients.
122
+ 3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
123
+ for **writes as well as reads**. Keep this setup's observations across batches.
124
+ Product creation includes its default Price; link creation expands line items.
125
+ Product and Price Search use studio/origin metadata. Existing links are fetched by
126
+ IDs from the verified configuration; Stripe has no Payment Link metadata search.
127
+ Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
128
+ it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
129
+ window, recover the studio's existing link IDs before setup to avoid duplicates.
130
+ Follow search `next_page` cursors. Search is eventually consistent: write responses
131
+ are the immediate evidence. On an uncertain write, retry its exact idempotency key;
132
+ never discard successful responses or invent IDs. Start a new read set when checking
133
+ later external edits, changing accounts, or changing mode.
134
+ 4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
135
+ `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
136
+ line items, menus, slot limits and portal settings. It saves compact public Worker
137
+ configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
138
+ Deploy installs those links. Checkout then reports only `webhook-secret` missing;
139
+ it cannot open until signed payment events can confirm orders. Changing shop.json,
140
+ the slot manifest or origin requires verification again. Local test configuration
141
+ goes to `.dev.vars`.
132
142
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
133
143
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
134
144
  checks the same account as the MCP, and installs the signing secret and verified
@@ -136,7 +146,7 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
136
146
  studio profile is not already connected. MCP approval cannot be reused or exported.
137
147
  Cloudflare authorization and an existing deployment are prerequisites.
138
148
  6. After installation, perform the returned `retire` operations through Stripe MCP to
139
- archive replaced catalog objects, then verify fresh reads again. Test a sandbox
149
+ archive replaced catalog objects. Use fresh scoped reads for later drift checks. Test a sandbox
140
150
  purchase and its signed event, order grant, booking capacity or recurring invoice;
141
151
  a browser return is never proof. Stripe CLI `trigger` can send a test event, and
142
152
  `events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
@@ -145,6 +155,19 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
145
155
 
146
156
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
147
157
 
158
+ **Why the separate approval (checked 2026-10-14).** Stripe's
159
+ [event destination API](https://docs.stripe.com/api/v2/core/event-destinations/create)
160
+ also returns its signing secret only in the private create response when explicitly
161
+ included. Thin events still require signature verification and authenticated retrieval
162
+ of event details; they do not supply a secret handoff to Wrangler. The
163
+ [official MCP](https://docs.stripe.com/mcp) documents no credential or secret transfer
164
+ to a studio Worker. Therefore the AI starts `stripe_webhook`, the owner clicks
165
+ Approve once in Stripe's CLI browser page if needed, and the tool completes endpoint
166
+ creation and Wrangler secret installation privately. MCP approval and CLI approval
167
+ are separate. No API key is requested, no secret enters chat, and Homie is never in
168
+ the money path. A connected studio CLI profile needs no further approval.
169
+
170
+
148
171
  The owner never handles an API key or signing secret. No Homie backend handles payments.
149
172
  The Worker needs only public link configuration and the signing secret; buyers pay
150
173
  Stripe directly even when the AI and the owner's computer are offline. The old
@@ -0,0 +1,8 @@
1
+ export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
2
+ if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
3
+ try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
4
+ const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
5
+ p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
6
+ const canvas=document.querySelector('canvas');
7
+ canvas.insertAdjacentElement('afterend',p);
8
+ }
@@ -4,26 +4,47 @@ export const APP_SHELL_JS = String.raw`(function () {
4
4
  if (!boot || boot.kind !== 'app') return;
5
5
  var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
6
6
  var params = new URLSearchParams(location.search);
7
- var inflight = 0;
7
+ // Share short-lived reads across same-origin screens with the exact same role/access.
8
+ // No records are persisted. Writes always reach the Worker and invalidate all views.
9
+ var recent = new Map(), failures = new Map(), pending = new Map();
10
+ var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
11
+ function accept(m) { if (m?.invalidate) { recent.clear(); failures.clear(); } else if (m?.path && m?.entry) recent.set(m.path, m.entry); }
12
+ if (channel) channel.onmessage = function(e) { accept(e.data); };
13
+ function share(m) { accept(m); if (channel) channel.postMessage(m); }
14
+ function locked(run) { return typeof navigator !== 'undefined' && navigator.locks ? navigator.locks.request('homie-app-records-v1', run) : run(); }
15
+ async function request(path, m) {
16
+ var read = m.method === 'GET';
17
+ if (read && pending.has(path)) return pending.get(path);
18
+ var job = locked(async function() {
19
+ var cached = recent.get(path);
20
+ if (read && cached && cached.until > Date.now()) return cached.result;
21
+ var result;
22
+ try {
23
+ var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: read ? undefined : JSON.stringify({ data: m.data, version: m.version }), signal: AbortSignal.timeout(8000) });
24
+ result = await res.json();
25
+ if (res.status === 429 || res.status >= 500) throw new Error('reconnect');
26
+ failures.delete(path);
27
+ if (read && result.ok) share({path: path, entry: {result: result, until: Date.now() + 1500}});
28
+ if (!read && result.ok) share({invalidate: true});
29
+ } catch (_) {
30
+ result = {ok: false, error: 'Reconnecting. Your saved updates will appear shortly.'};
31
+ if (read) { var count = (failures.get(path) || 0) + 1; failures.set(path, count); share({path: path, entry: {result: result, until: Date.now() + Math.min(30000, 1000 * Math.pow(2, count - 1))}}); }
32
+ }
33
+ return result;
34
+ });
35
+ if (read) { pending.set(path, job); job.finally(function() { pending.delete(path); }); }
36
+ return job;
37
+ }
8
38
  addEventListener('message', async function (e) {
9
39
  var m = e.data;
10
40
  if (frame && e.source === frame.contentWindow && m?.type === 'homie-app-ticket' && /^[a-f0-9]{32}$/.test(m.ticket)) { params.set('ticket', m.ticket); history.replaceState(null, '', location.pathname + '?' + params); return; }
11
41
  if (!frame || e.source !== frame.contentWindow || !m || m.type !== 'homie-app' || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
12
42
  if (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record))) return;
13
- var result;
14
- if (inflight >= 8) result = { ok: false, error: 'too many requests' };
15
- else {
16
- inflight++;
17
- try {
18
- var q = new URLSearchParams();
19
- ['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
20
- if (boot.screen && !q.has('role')) q.set('surface', 'wall');
21
- var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
22
- var res = await fetch(path, { method: m.method, headers: { 'content-type': 'application/json' }, body: m.method === 'GET' ? undefined : JSON.stringify({ data: m.data, version: m.version }) });
23
- result = await res.json();
24
- } catch (err) { result = { ok: false, error: 'records unavailable; reconnect and retry' }; }
25
- finally { inflight--; }
26
- }
43
+ var q = new URLSearchParams();
44
+ ['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
45
+ if (boot.screen && !q.has('role')) q.set('surface', 'wall');
46
+ var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
47
+ var result = await request(path, m);
27
48
  e.source.postMessage({ type: 'homie-app-result', id: m.id, result: result }, '*');
28
49
  });
29
50
  })();`;
@@ -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.51.6","sha256":"035298d6bd38bab86e0471ea08819786995a53cedb8da961b595506e5403b410"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.8","sha256":"ac2d23a60cc25247d09af506854dec3345b9219d57dbde5f3b1abe5640abde84"});
@@ -0,0 +1,11 @@
1
+ import {oauthStorage} from './mcp-store.mjs';
2
+ /** A hashed bearer, bound to this origin and exact tool names; re-read on every dispatch. */
3
+ export async function tokenCaller(request,env) {
4
+ const token=/^Bearer (homie_owner_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
5
+ const hash=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token));
6
+ const id=Array.from(new Uint8Array(hash),b=>b.toString(16).padStart(2,'0')).join('');
7
+ const row=await oauthStorage(env.DB).get('connector:'+id,'json');
8
+ if(!row||row.origin!==new URL(request.url).origin||row.expires<=Date.now()/1000)return null;
9
+ return {id:'connector:'+id,owner:true,client:'owner-connector',connection:'connector:'+id,tools:row.tools};
10
+ }
11
+ export function toolInScope(caller,name){return !caller.tools||caller.tools.includes(name);}
package/worker/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {appGraphicsFallback} from './app-graphics.mjs';
2
+ import {JOIN_AREA_FRAME_JS} from './join-area.mjs';
1
3
  import { directoryUrl } from './directory-choice.mjs';
2
4
  import {APP_STORAGE_JS} from './app-frame.mjs';
3
5
  import {recordsPage} from './records-page.mjs';
@@ -506,6 +508,7 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
506
508
  const params = paramsFrom(url.searchParams, meta);
507
509
  if (meta.kind === 'app' && !params.role) params.role = appRole(meta, url);
508
510
  const cfg = {
511
+ ...(meta.kind === 'app' ? {kind: 'app'} : {}),
509
512
  v: 1,
510
513
  url: `${wsBase}/${game}/__net?room=${encodeURIComponent(room)}${b ? `&b=${b}` : ''}${t ? `&t=${encodeURIComponent(t)}` : ''}${watching ? '&w=1' : ''}${ver ? `&gv=${encodeURIComponent(ver)}` : ''}`,
511
514
  room,
@@ -534,8 +537,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
534
537
  ...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
535
538
  };
536
539
  let html = await res.text();
537
- 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>` : '';
538
- 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>`;
540
+ const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
541
+ const head = `<script>${JOIN_AREA_FRAME_JS}</script>` + (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>`;
539
542
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
540
543
  return new Response(html, {
541
544
  headers: {
@@ -0,0 +1,35 @@
1
+ /** The shell owns these measurements; sandboxed views receive only geometry. */
2
+ export function joinAreaShell() {
3
+ const frame=document.querySelector('iframe.game');
4
+ const update=()=>{
5
+ const card=document.querySelector('[data-join]');
6
+ const r=card?.getBoundingClientRect();
7
+ const visible=!!(r?.width&&r.height&&getComputedStyle(card).visibility!=='hidden'&&getComputedStyle(card).display!=='none');
8
+ const corner=card?.className??'';
9
+ const box=visible?{x:r.x,y:r.y,width:r.width,height:r.height,top:corner.includes('top')?r.bottom:0,bottom:corner.includes('top')?0:innerHeight-r.top,left:corner.includes('left')?r.right:0,right:corner.includes('left')?0:innerWidth-r.left}:{};
10
+ document.body.classList.toggle('homie-join-visible',visible);
11
+ for(const k of ['top','bottom','left','right'])document.documentElement.style.setProperty('--homie-join-inset-'+k,(box[k]??0)+'px');
12
+ for(const k of ['x','y','width','height'])document.documentElement.style.setProperty('--homie-join-'+k,(box[k]??0)+'px');
13
+ frame?.contentWindow?.postMessage({type:'homie-join-area',visible,box},'*');
14
+ };
15
+ new ResizeObserver(update).observe(document.body);
16
+ const card=document.querySelector('[data-join]');if(card)new ResizeObserver(update).observe(card);
17
+ new MutationObserver(update).observe(document.body,{childList:true,subtree:true,attributes:true,attributeFilter:['hidden','class']});
18
+ frame?.addEventListener('load',update);addEventListener('resize',update);
19
+ addEventListener('message',e=>{if(e.source===frame?.contentWindow&&e.data?.type==='homie-join-ready')update();});
20
+ update();
21
+ }
22
+ export function joinAreaFrame() {
23
+ addEventListener('message',e=>{
24
+ if(e.source!==parent||e.data?.type!=='homie-join-area')return;
25
+ const {box,visible}=e.data;
26
+ document.body?.classList.toggle('homie-join-visible',!!visible);
27
+ for(const k of ['top','bottom','left','right','x','y','width','height']) {
28
+ const v=box?.[k];if(v!==undefined&&(!Number.isFinite(v)||v<0))return;
29
+ document.documentElement.style.setProperty('--homie-join-'+(['top','bottom','left','right'].includes(k)?'inset-':'')+k,(v??0)+'px');
30
+ }
31
+ });
32
+ addEventListener('DOMContentLoaded',()=>parent.postMessage({type:'homie-join-ready'},'*'));
33
+ }
34
+ export const JOIN_AREA_SHELL_JS=`(${joinAreaShell.toString()})();`;
35
+ export const JOIN_AREA_FRAME_JS=`(${joinAreaFrame.toString()})();`;
@@ -12,6 +12,7 @@ export async function allowed(audience, caller, env) {
12
12
  if (audience === 'public') return true;
13
13
  if (!caller.id) return false;
14
14
  if (audience === 'signed-in') return true;
15
+ if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
15
16
  if (audience === 'owner') return caller.owner === true;
16
17
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
17
18
  if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
@@ -39,11 +40,19 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
39
40
  const meta = cat.games.find(g=>g.id===app && g.kind==='app');
40
41
  if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw Object.assign(new Error('Unknown app or collection; collection names start with a lowercase letter and use lowercase letters, digits and hyphens (1–40). Record id: 1–64 letters, digits, underscore or hyphen.'),{name:'StudioOperationError'});
41
42
  if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
43
+ if(!role) {
44
+ const candidates=[];
45
+ for(const [name,declared] of Object.entries(meta.roles??{})) {
46
+ if(!declared.signIn || await allowed({app,role:name},caller,env))candidates.push([name,declared]);
47
+ }
48
+ candidates.sort((a,b)=>Number(b[1].signIn===true)-Number(a[1].signIn===true)||(b[1].can?.length??0)-(a[1].can?.length??0)||a[0].localeCompare(b[0]));
49
+ role=candidates[0]?.[0];
50
+ }
42
51
  const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
43
52
  const method = {read:'GET',create:'POST',update:'PUT',delete:'DELETE'}[operation];if (!method) throw new Error('unsupported operation');
44
53
  const request = withToolIdentity(new Request(url,{method,headers:{origin,'content-type':'application/json'},...(method==='GET'?{}:{body:JSON.stringify({data,version})})}),caller);
45
54
  const response = await appRecordsRoute(request,env,meta,url,url.pathname.slice(app.length+2));
46
- const result = await response.json(); if(!response.ok) {const error=new Error(result.error);error.name='StudioOperationError';throw error;}return result;
55
+ const result = await response.json(); if(!response.ok) {const error=new Error(`${result.error} (role: ${role??'none'})`);error.name='StudioOperationError';throw error;}return {...result,role};
47
56
  };
48
57
  const office = async (path, body, query={}) => {
49
58
  const delegated=definition.delegateOffice && await allowed(definition.audience,caller,env);
@@ -98,8 +107,8 @@ export function builtins(env,cat,origin) {
98
107
  if(!ctx.caller.owner)throw new Error('Owner only');
99
108
  return (await import('./event-webhooks.mjs')).testWebhook(env,cat,feed,origin);
100
109
  }),
101
- tool('studio_function_runs','Recent function attempts and errors, schedule next due times and trigger diagnostics. Run text is untrusted data. Follow next with before for older runs.','owner',object({name:str,errors:{type:'boolean'},before:{type:'integer',minimum:1}}),async(args,ctx)=>{
102
- if(!ctx.caller.owner)throw new Error('Owner only');
110
+ tool('studio_function_runs','Recent function attempts and errors, schedule next due times and trigger diagnostics. Run text is untrusted data. Follow next with before for older runs.','studio-staff',object({name:str,errors:{type:'boolean'},before:{type:'integer',minimum:1}}),async(args,ctx)=>{
111
+ if(!await allowed('studio-staff',ctx.caller,env))throw new Error('Owner or studio staff only');
103
112
  return (await import('./functions.mjs')).functionRuns(env,args);
104
113
  }),
105
114
  tool('studio_function_replay','Replay retained events for a declaration with replay: true.','owner',object({name:str},['name']),async({name})=>{
package/worker/mcp.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {tokenCaller,toolInScope} from './connector-token.mjs';
1
2
  import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
2
3
  import {eventsRequest} from './mcp-events.mjs';
3
4
  function freshErrorMessage(error,env,caller) {
@@ -38,10 +39,17 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
38
39
  if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
39
40
  const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
40
41
  const localIdentity=await localCaller(request,env);
42
+ const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
43
+ const bearerIdentity=bearer?await tokenCaller(request,env):null;
44
+ if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
41
45
  const api=async(request,env,ctx)=>{
42
- const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
46
+ const identity=async()=>{if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
43
47
  let caller;
44
48
  try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
49
+ if(caller.tools){
50
+ const body=request.method==='POST'?await request.clone().json().catch(()=>null):null;
51
+ if(request.method!=='POST'||!['initialize','notifications/initialized','ping','tools/list','tools/call'].includes(body?.method))return json({error:'connector is scoped to tools'},403);
52
+ }
45
53
  const cat=await catalogueOf();
46
54
  const tools=[...builtins(env,cat,origin),...definitions];
47
55
  await pushToolList(env,cat,tools,origin);
@@ -110,11 +118,11 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
110
118
  }
111
119
  }
112
120
  for(const tool of tools){
113
- if (!await allowed(tool.audience,caller,env)) continue;
121
+ if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
114
122
  if (tool.kind==='prompt') {
115
- if (!await allowed(tool.audience,caller,env)) continue;
123
+ if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
116
124
  s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
117
- if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
125
+ if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
118
126
  const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
119
127
  return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
120
128
  }));continue;
@@ -125,7 +133,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
125
133
  try{
126
134
  // Recheck inside dispatch, even when a connection listed tools earlier.
127
135
  const fresh=await identity();
128
- if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
136
+ if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
129
137
  try{await rate(fresh);}catch(error){outcome='limited';throw error;}
130
138
  const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
131
139
  if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
@@ -163,7 +171,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
163
171
  };
164
172
  return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
165
173
  };
166
- if(localIdentity)return api(request,scoped,ctx);
174
+ if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
167
175
  const provider=new OAuthProvider({
168
176
  apiRoute:'/mcp',apiHandler:{fetch:api},
169
177
  authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
package/worker/pages.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {JOIN_AREA_SHELL_JS} from './join-area.mjs';
1
2
  import { appWords, openPath } from './app-format.mjs';
2
3
  import { APP_SHELL_JS } from './app-shell.mjs';
3
4
  /**
@@ -142,11 +143,13 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
142
143
  .card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
143
144
  .card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
144
145
  .card ol { margin: 0; padding-left: 18px; }
145
- .join { position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
146
+ :root{--homie-join-inset-top:0px;--homie-join-inset-right:0px;--homie-join-inset-bottom:0px;--homie-join-inset-left:0px;--homie-join-x:0px;--homie-join-y:0px;--homie-join-width:0px;--homie-join-height:0px}
147
+ .join { box-sizing:border-box;width:480px;max-width:calc(100vw - 32px);max-height:280px;overflow:hidden; position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
148
+ .join[hidden]{display:none}
146
149
  .join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
147
150
  .join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
148
151
  .join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
149
- .join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
152
+ .join > div:last-child{min-width:0;overflow-wrap:anywhere}.join .qr { flex-shrink:0; width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
150
153
  .join .qr svg { width: 100%; height: 100%; display: block; }
151
154
  .join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
152
155
  .join span { opacity: .85; word-break: break-all; }
@@ -248,7 +251,7 @@ ${arrive.html}
248
251
  <div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
249
252
  <div class="card" data-results hidden></div>
250
253
  <div class="card" data-screen hidden></div>
251
- ${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
254
+ ${joinCard}${joinCard ? `<script>${JOIN_AREA_SHELL_JS}</script>` : ''}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
252
255
  <script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
253
256
  <script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
254
257
  <script>${CHAT_JS}</script>
@@ -2,12 +2,13 @@ import {playerProperties} from './embed.mjs';
2
2
  import {appRole} from './app-format.mjs';
3
3
  import {esc} from './site.mjs';
4
4
  import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
5
+ import {RECORDS_PREFS_JS} from './records-prefs.mjs';
5
6
  import {APP_SHELL_JS} from './app-shell.mjs';
6
7
  /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
7
8
  export function recordsPage(meta,url,cat={},shop=null) {
8
- const params=new URLSearchParams(url.search);params.delete('access');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
9
+ const params=new URLSearchParams(url.search);params.delete('access');params.set('pf','1');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
9
10
  const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
10
11
  const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
12
  const source=`/${meta.id}/__game/?${params}`;
12
- return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
13
+ return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${RECORDS_PREFS_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
13
14
  }
@@ -0,0 +1,24 @@
1
+ /** The records-only shell keeps net.prefs outside its opaque app frame. */
2
+ export const RECORDS_PREFS_JS=String.raw`(()=>{
3
+ const boot=window.__HOMIE_APP,frame=document.querySelector('iframe.game');
4
+ const storageKey='homie-prefs.'+boot.game;let memory=null;
5
+ function read(){if(memory)return memory;try{const v=JSON.parse(localStorage.getItem(storageKey)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{};}catch{return memory={};}}
6
+ addEventListener('message',e=>{
7
+ const m=e.data;if(e.source!==frame.contentWindow||m?.t!=='homie-net'||m.what!=='prefs')return;
8
+ const result={t:'homie-prefs',n:m.n,ok:false};
9
+ try{
10
+ const all=read(),key=m.k;
11
+ if(m.op==='all'){result.ok=true;result.all=all;}
12
+ else if(typeof key!=='string'||!key.length||key.length>64||key==='__proto__')result.why='key';
13
+ else if(m.op==='set'||m.op==='del'){
14
+ const next=Object.fromEntries(Object.entries(all).filter(([k])=>k!==key));
15
+ if(m.op==='set'&&m.v!=null)next[key]=m.v;
16
+ const text=JSON.stringify(next);
17
+ if(text.length>16384||Object.keys(next).length>32)result.why='too-large';
18
+ else if(typeof m.v==='number'&&!Number.isFinite(m.v))result.why='value';
19
+ else{result.ok=true;result.kept='kept';try{if(memory)throw Error('memory');localStorage.setItem(storageKey,text);}catch{memory=next;result.kept='memory';}}
20
+ }else result.why='op';
21
+ }catch{result.why='error';}
22
+ e.source.postMessage(result,'*');
23
+ });
24
+ })();`;
package/worker/shop.mjs CHANGED
@@ -632,7 +632,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
632
632
  const reference = b.body.reference ?? null;
633
633
  if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
634
634
  const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
635
- if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
635
+ if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
636
636
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
637
637
  // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
638
638
  if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
@@ -691,7 +691,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
691
691
  async function parentLinkRoute(request, env, url, cat, shop, ready) {
692
692
  const b = await bodyOf(request, requestSize(shop));
693
693
  if (b.error) return b.error;
694
- if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, migration: ready.migration });
694
+ if (!ready.ready) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
695
695
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1 }];
696
696
  if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
697
697
  const game = GAME_ID.test(String(b.body.game ?? '')) ? String(b.body.game) : null;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.51.6';
2
+ export const STUDIO_VERSION_TAG = '0.51.8';