@homie-rocks/studio 0.50.6 → 0.51.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 (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/app-starters/giving/README.md +25 -2
  3. package/app-starters/giving/app.json +16 -49
  4. package/app-starters/giving/index.html +1 -1
  5. package/app-starters/giving/src/rules.ts +1 -1
  6. package/app-starters/giving/src/view.ts +57 -59
  7. package/apps/APPS.md +70 -2
  8. package/apps/apps.ts +1 -1
  9. package/bin/homie-studio.mjs +8 -4
  10. package/functions/FUNCTIONS.md +39 -2
  11. package/guides/app/SKILL.md +61 -5
  12. package/guides/functions/SKILL.md +30 -0
  13. package/guides/office/SKILL.md +20 -0
  14. package/guides/servers/SKILL.md +18 -0
  15. package/guides/shop/SKILL.md +41 -20
  16. package/lib/app-needs.mjs +31 -11
  17. package/lib/app-ready.mjs +49 -0
  18. package/lib/check.mjs +51 -35
  19. package/lib/dev-stripe-fixture.mjs +37 -0
  20. package/lib/dev-stripe-sandbox.mjs +80 -0
  21. package/lib/dev.mjs +19 -17
  22. package/lib/events-cli.mjs +5 -1
  23. package/lib/local-test.mjs +18 -0
  24. package/lib/mcp-tools.mjs +15 -1
  25. package/lib/office.mjs +1 -1
  26. package/lib/scaffold.mjs +22 -1
  27. package/lib/shop-links.mjs +79 -7
  28. package/lib/shop.mjs +4 -2
  29. package/lib/stripe-connect.mjs +1 -0
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +3 -0
  32. package/package.json +1 -1
  33. package/shop/SELLING.md +2 -2
  34. package/shop/SHOP.md +120 -5
  35. package/shop/shop.ts +29 -0
  36. package/tools/tools.ts +8 -1
  37. package/worker/app-format.mjs +8 -1
  38. package/worker/app-frame.mjs +9 -0
  39. package/worker/app-records.mjs +39 -10
  40. package/worker/booking-store.mjs +79 -0
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/event-resources.mjs +30 -3
  43. package/worker/functions.mjs +5 -2
  44. package/worker/index.mjs +17 -3
  45. package/worker/local-test.mjs +19 -0
  46. package/worker/mcp-tools.mjs +19 -8
  47. package/worker/mcp.mjs +23 -11
  48. package/worker/records-page.mjs +13 -0
  49. package/worker/shop-links.mjs +3 -2
  50. package/worker/shop-page.mjs +27 -5
  51. package/worker/shop-rules.mjs +2 -1
  52. package/worker/shop-store.mjs +1 -1
  53. package/worker/shop-subscriptions.mjs +198 -0
  54. package/worker/shop.mjs +195 -29
  55. package/worker/tool-database.mjs +36 -0
  56. package/worker/version.mjs +1 -1
package/worker/index.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import {APP_STORAGE_JS} from './app-frame.mjs';
2
+ import {recordsPage} from './records-page.mjs';
1
3
  import { RULES_WIRING_MESSAGE } from './rules-wiring.mjs';
2
4
  import { characterStore, characterRequest } from './characters.mjs';
3
5
  export { useFunctions } from './functions.mjs';
@@ -183,7 +185,10 @@ async function sellingShop(env, cat) {
183
185
  }
184
186
 
185
187
  async function availableShellShop(env, cat, game, options) {
186
- return await sellingShop(env, cat) ? shellShop(cat, game, options) : null;
188
+ // The shell can show a disabled catalog before payment setup is complete.
189
+ // Checkout readiness is enforced by the shop API, separately from visibility.
190
+ const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
191
+ return (preview && !shopOf(cat)?.broken || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
187
192
  }
188
193
 
189
194
  async function postsOf(env, origin) {
@@ -529,8 +534,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
529
534
  ...(!agent && !watching && await availableShellShop(env, cat, game) ? { shop: true } : {}),
530
535
  };
531
536
  let html = await res.text();
532
- const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',function(){if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;try{var c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch(e){}var p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Try a browser with WebGL enabled; use any available controls below.').replace(/</g, '\u003c')};p.style.cssText='position:fixed;z-index:2147483647;left:16px;right:16px;top:80px;padding:16px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';document.body.append(p);});</script>` : '';
533
- const head = fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
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>`;
534
539
  html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
535
540
  return new Response(html, {
536
541
  headers: {
@@ -610,6 +615,7 @@ export default {
610
615
  async scheduled(event, env, ctx) {
611
616
  const maintenance=(async () => {
612
617
  const cat = await catalogue(env, 'https://studio.invalid');
618
+ await notifyTools(env,cat,cat.studio?.url??'https://studio.invalid').catch(error=>console.warn('Tool catalogue refresh:',error.message));
613
619
  await reconcileOrders(env, shopOf(cat));
614
620
  await (await import('./functions.mjs')).scheduleFunctions(event,env,cat,cat.studio?.url??'https://studio.invalid');
615
621
  })();
@@ -638,6 +644,8 @@ export default {
638
644
  try { console.error(JSON.stringify({ at: new Date().toISOString(), ev: 'lobby-route-failed', path: url.pathname, error: errorLine(error) })); } catch { /* no console */ }
639
645
  res = json({ ok: false, error: 'failed' }, 500);
640
646
  } finally {
647
+ // Refresh after the route has populated the catalogue cache.
648
+ if(env.DB&&env.LOBBY&&ctx?.waitUntil)ctx.waitUntil(catalogue(env,url.origin).then(cat=>notifyTools(env,cat,url.origin)).catch(()=>{}));
641
649
  // A route can commit a write and then fail. Its outbox still needs delivery.
642
650
  if(env.DB) {
643
651
  const delivery=dispatch();
@@ -649,6 +657,11 @@ export default {
649
657
  },
650
658
  };
651
659
 
660
+ async function notifyTools(env,cat,origin) {
661
+ if(!env.DB||!env.LOBBY)return;
662
+ const [{pushToolList},{builtins}]=await Promise.all([import('./event-resources.mjs'),import('./mcp-tools.mjs')]);
663
+ await pushToolList(env,cat,[...builtins(env,cat,origin),...await studioTools()],origin);
664
+ }
652
665
  let studioTools = async () => [];
653
666
  /** Studio-authored Worker code, loaded only on the MCP surface. */
654
667
  export function useTools(load) { studioTools = load; }
@@ -920,6 +933,7 @@ async function route(request, env, ctx) {
920
933
  if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
921
934
  return json({ ok: false, error: a.error }, a.status);
922
935
  }
936
+ if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game));
923
937
  }
924
938
  }
925
939
  const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
@@ -0,0 +1,19 @@
1
+ /** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
2
+ const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
3
+ export async function localCaller(request, env) {
4
+ if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
5
+ return {id:'local-test-owner',owner:true,client:null};
6
+ }
7
+ export async function localSession(request, env) {
8
+ if (!local(request, env)) return null;
9
+ const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
10
+ if (!token) return null;
11
+ try {
12
+ const [data, signature] = token.split('.');
13
+ if (!/^[a-f0-9]{64}$/.test(signature)) return null;
14
+ const key = await crypto.subtle.importKey('raw',new TextEncoder().encode(env.HOMIE_LOCAL_TEST_SECRET),{name:'HMAC',hash:'SHA-256'},false,['verify']);
15
+ if (!await crypto.subtle.verify('HMAC',key,Uint8Array.from(signature.match(/../g),h=>parseInt(h,16)),new TextEncoder().encode(data))) return null;
16
+ const session = JSON.parse(atob(data.replaceAll('-','+').replaceAll('_','/')));
17
+ return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
18
+ } catch { return null; }
19
+ }
@@ -1,3 +1,4 @@
1
+ import {toolDatabase} from './tool-database.mjs';
1
2
  import { selling } from './extensions.mjs';
2
3
  import { withToolIdentity } from './tool-identity.mjs';
3
4
  import { officeRoutes } from './office.mjs';
@@ -13,7 +14,7 @@ export async function allowed(audience, caller, env) {
13
14
  if (audience === 'signed-in') return true;
14
15
  if (audience === 'owner') return caller.owner === true;
15
16
  if (audience && typeof audience === 'object' && audience.app && audience.role) {
16
- return caller.owner || Boolean(await env.DB.prepare('SELECT player FROM app_roles WHERE app=?1 AND role=?2 AND player=?3').bind(audience.app,audience.role,caller.id).first());
17
+ return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
17
18
  }
18
19
  return false;
19
20
  }
@@ -35,7 +36,7 @@ export async function currentCaller(env, props) {
35
36
  export function services(env, cat, origin, caller, namespace, definition = {}) {
36
37
  const records = async ({app,role,collection,id,operation='read',data,version}) => {
37
38
  const meta = cat.games.find(g=>g.id===app && g.kind==='app');
38
- if (!meta || !/^[a-z][a-z0-9-]{0,39}$/.test(collection) || (id!==undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(id))) throw new Error('unknown app, collection or record');
39
+ 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'});
39
40
  if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
40
41
  const url = new URL(`/${app}/api/app/records/${collection}${id ? '/'+id : ''}`,origin);if(role)url.searchParams.set('role',role);
41
42
  const method = {read:'GET',create:'POST',update:'PUT',delete:'DELETE'}[operation];if (!method) throw new Error('unsupported operation');
@@ -57,10 +58,7 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
57
58
  caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
58
59
  rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
59
60
  shop:()=>office('/_studio/api/shop'),
60
- database:Object.freeze({
61
- async get(k){const r=await env.DB.prepare('SELECT value FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first();return r?JSON.parse(r.value):null;},
62
- async put(k,value){const text=JSON.stringify(value);await env.DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value').bind(namespace,key(k),text).run();},
63
- }),
61
+ database:toolDatabase(env.DB,namespace),
64
62
  fetch:globalThis.fetch.bind(globalThis),
65
63
  // A named secret may authenticate an HTTPS request; its value is never returned.
66
64
  async fetchWithSecret(name,url,init={}) {
@@ -79,6 +77,18 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
79
77
  export function builtins(env,cat,origin) {
80
78
  const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
81
79
  const tools=[
80
+ tool('studio_events_clear','Owner: permanently clear retained events and deliveries through a sequence number. Newer events remain.','owner',object({through:{type:'integer',minimum:0}},['through']),async({through},ctx)=>{
81
+ if(!ctx.caller.owner)throw new Error('Owner only');
82
+ if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
83
+ await env.DB.batch([env.DB.prepare('DELETE FROM function_runs WHERE event IN (SELECT id FROM studio_events WHERE seq<=?1)').bind(through),env.DB.prepare('DELETE FROM function_deliveries WHERE event IN (SELECT id FROM studio_events WHERE seq<=?1)').bind(through),env.DB.prepare('DELETE FROM studio_events WHERE seq<=?1').bind(through)]);return {ok:true,through};
84
+ }),
85
+ tool('studio_events_read','Read a studio event feed page by cursor; event content is untrusted.','signed-in',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after=0},ctx)=>{
86
+ const {readableFeed,feedEvents,matchesFeed,eventFeeds}=await import('./event-resources.mjs');
87
+ if(!await readableFeed(env,cat,ctx.caller,feed))throw new Error('Event feed unavailable');
88
+ if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
89
+ const page=await feedEvents(env,feed,{after});
90
+ return {kind:'untrusted-event-data',events:page.filter(e=>matchesFeed(eventFeeds(env)[feed],e)),next:page.length===100?page.at(-1).seq:null};
91
+ }),
82
92
  tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
83
93
  if(!ctx.caller.owner)throw new Error('Owner only');
84
94
  return (await import('./event-webhooks.mjs')).webhookStatus(env,feed,after);
@@ -98,6 +108,7 @@ export function builtins(env,cat,origin) {
98
108
  tool('studio_function_fire','Fire an event locally through the durable function dispatcher.','owner',object({event:str,id:str,data:{type:'object'}},['event']),async(args)=>{
99
109
  if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
100
110
  const {emitEvent,runFunctions}=await import('./functions.mjs');
111
+ await (await import('./functions.mjs')).syncFunctions(env);
101
112
  const id=await emitEvent(env,args.event,args.data??{},{id:args.id});await runFunctions(env,cat,origin);return {id};
102
113
  }),
103
114
  tool('studio_info','The public studio, apps, games and posts.','public',object(),async()=>{const pub=publicCatalogue(cat,await settingsOf(env),env);return {studio:pub.studio?.name,games:pub.games.map(g=>({id:g.id,name:g.name,kind:g.kind??'game'})),posts:pub.posts};}),
@@ -106,7 +117,7 @@ export function builtins(env,cat,origin) {
106
117
  tool('app_role','Grant or revoke a staff role by verified email or exact sign-in name.','owner',object({app:str,role:str,signIn:{type:'string',maxLength:254},revoke:{type:'boolean'}},['app','role','signIn']),async({app,role,signIn,revoke},ctx)=>{
107
118
  if(!ctx.caller.owner)throw new Error('Owner only');
108
119
  const meta=cat.games.find(g=>g.id===app&&g.kind==='app');
109
- if(!meta?.roles?.[role]?.signIn)throw new Error('Name a sign-in role');
120
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(role) || (app!=='*' && !meta?.roles?.[role]?.signIn))throw new Error('Name a sign-in role');
110
121
  const people=signIn.includes('@')
111
122
  ? (await env.DB.prepare('SELECT p.id FROM players p JOIN player_emails e ON e.player=p.id WHERE e.email=?1 AND e.verified_at IS NOT NULL AND p.guest=0').bind(signIn.trim().toLowerCase()).all()).results
112
123
  : (await env.DB.prepare('SELECT id FROM players WHERE name=?1 AND guest=0').bind(signIn).all()).results;
@@ -125,7 +136,7 @@ export function builtins(env,cat,origin) {
125
136
  const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
126
137
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
127
138
  }),
128
- tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:str,id:str,operation:{enum:['read','create','update','delete']},data:{type:'object'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
139
+ tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'1–64 letters, digits, underscore or hyphen'},operation:{enum:['read','create','update','delete']},data:{type:'object',propertyNames:{pattern:'^[a-z][a-z0-9_]*$'},description:'Field names start with a lowercase letter and use lowercase letters, digits and underscore; discover fields with app_schema.'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
129
140
  ];
130
141
  for(const [name,path,description] of [
131
142
  ['room_announce','announce','Announce to a room.'],['room_kick','kick','Ask the owner to remove a player.'],['room_mute','mute','Ask the owner to mute a player.'],['room_close','close','Ask the owner to close a room.'],['studio_game','game','Ask the owner to change game access or room size.'],['shop_refund','shop/refund','Ask the owner to refund an order.'],
package/worker/mcp.mjs CHANGED
@@ -1,4 +1,10 @@
1
- import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,eventHub} from './event-resources.mjs';
1
+ function freshErrorMessage(error,env,caller) {
2
+ let message=String(error?.message??error);
3
+ for(const [key,value] of Object.entries(env))if(typeof value==='string'&&/SECRET|KEY|TOKEN|PASSWORD/.test(key)&&value.length)message=message.replaceAll(value,'[redacted]');
4
+ return message;
5
+ }
6
+ import {localCaller} from './local-test.mjs';
7
+ import {eventFeeds,feedUri,readableFeed,feedEvents,matchesFeed,subscribeFeed,eventHub,pushToolList} from './event-resources.mjs';
2
8
  import { selling } from './extensions.mjs';
3
9
  import OAuthProvider from '@cloudflare/workers-oauth-provider';
4
10
  import { createMcpHandler } from 'agents/mcp/server';
@@ -29,15 +35,19 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
29
35
  if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
30
36
  if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
31
37
  const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
38
+ const localIdentity=await localCaller(request,env);
32
39
  const api=async(request,env,ctx)=>{
40
+ const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
33
41
  let caller;
34
- try{caller=await currentCaller(env,ctx.props);}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"`}});}
42
+ 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"`}});}
35
43
  const cat=await catalogueOf();
36
- if(request.method==='GET'&&Object.keys(eventFeeds(env)).length&&caller.connection) {
44
+ const tools=[...builtins(env,cat,origin),...definitions];
45
+ await pushToolList(env,cat,tools,origin);
46
+ if(request.method==='GET'&&env.LOBBY) {
37
47
  if(!request.headers.get('accept')?.includes('text/event-stream'))return json({error:'Accept text/event-stream required'},406);
38
- return eventHub(env,caller.connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'stream',connection:caller.connection,lastId:request.headers.get('last-event-id'),cat,origin})});
48
+ const connection=caller.connection??'public:'+crypto.randomUUID();
49
+ return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'stream',connection,lastId:request.headers.get('last-event-id'),cat,origin,tools:tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}))})});
39
50
  }
40
- const tools=[...builtins(env,cat,origin),...definitions];
41
51
  if(request.method==='POST') {
42
52
  const body=await request.clone().json().catch(()=>null);
43
53
  if(body?.method==='tools/call') {
@@ -53,7 +63,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
53
63
  };
54
64
  const read=async(name,handler)=>{
55
65
  let outcome='error';
56
- try{const fresh=await currentCaller(env,ctx.props);await rate(fresh);const result=await handler(fresh);outcome='ok';return result;}
66
+ try{const fresh=await identity();await rate(fresh);const result=await handler(fresh);outcome='ok';return result;}
57
67
  finally{
58
68
  try{await audit(env,caller,name,outcome);}
59
69
  finally{const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;}
@@ -61,7 +71,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
61
71
  };
62
72
  const server=async()=>{
63
73
  const capabilities=await selling.machineCapabilities(env);
64
- const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
74
+ const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
65
75
  const dispatch=new Map();
66
76
  for(const name of Object.keys(eventFeeds(env))) {
67
77
  if(!await readableFeed(env,cat,caller,name))continue;
@@ -79,7 +89,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
79
89
  }
80
90
  if(Object.keys(eventFeeds(env)).length)for(const method of ['resources/subscribe','resources/unsubscribe']) {
81
91
  s.server.setRequestHandler(method,async input=>{
82
- const fresh=await currentCaller(env,ctx.props);
92
+ const fresh=await identity();
83
93
  await subscribeFeed(env,cat,fresh,input.params.uri,method==='resources/unsubscribe');return {};
84
94
  });
85
95
  }
@@ -111,7 +121,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
111
121
  let outcome='error';
112
122
  try{
113
123
  // Recheck inside dispatch, even when a connection listed tools earlier.
114
- const fresh=await currentCaller(env,ctx.props);
124
+ const fresh=await identity();
115
125
  if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
116
126
  try{await rate(fresh);}catch(error){outcome='limited';throw error;}
117
127
  const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
@@ -120,7 +130,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
120
130
  const value=await tool.handler(args,{...context,request});
121
131
  const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
122
132
  outcome='ok';return {content:[{type:'text',text}]};
123
- }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:'Tool failed. Check the office audit and the tool definition.'}]};}
133
+ }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
124
134
  finally{
125
135
  try { await audit(env,caller,tool.name,outcome); }
126
136
  finally {
@@ -141,7 +151,8 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
141
151
  const entry=dispatch.get(input.params.name);
142
152
  if(!entry)return {isError:true,content:[{type:'text',text:'Permission denied or unknown tool'}]};
143
153
  const args=input.params.arguments??{};
144
- if(!validator.getValidator(entry.schema)(args).valid)return {isError:true,content:[{type:'text',text:'Invalid arguments'}]};
154
+ const validation=validator.getValidator(entry.schema)(args);
155
+ if(!validation.valid)return {isError:true,content:[{type:'text',text:'Invalid arguments: '+JSON.stringify(validation.errorMessage??validation.errors??entry.schema)}]};
145
156
  try{return await entry.handler(args,{...extra,_meta:input.params._meta});}
146
157
  catch(error){if(error?.code===-32042)throw new ProtocolError(error.code,error.message,error.data);throw error;}
147
158
  });
@@ -149,6 +160,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
149
160
  };
150
161
  return createMcpHandler(server)(request,env,ctx);
151
162
  };
163
+ if(localIdentity)return api(request,scoped,ctx);
152
164
  const provider=new OAuthProvider({
153
165
  apiRoute:'/mcp',apiHandler:{fetch:api},
154
166
  authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
@@ -0,0 +1,13 @@
1
+ import {playerProperties} from './embed.mjs';
2
+ import {appRole} from './app-format.mjs';
3
+ import {esc} from './site.mjs';
4
+ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
5
+ import {APP_SHELL_JS} from './app-shell.mjs';
6
+ /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
7
+ 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 tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
10
+ const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
11
+ 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="autoplay *; fullscreen *"></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
+ }
@@ -2,9 +2,9 @@
2
2
  import { KEY_SHAPE, modeOf } from './stripe.mjs';
3
3
  // Current documented Payment Link fields, isolated from the existing keyed transport version.
4
4
  export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
5
- export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
5
+ export const hasStripeKey = (env) => env?.STRIPE_CHECKOUT_MODE === 'sessions' && KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
6
6
  export function linkConfig(env) {
7
- try { const c = JSON.parse(env?.STRIPE_SHOP_LINKS ?? 'null'); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
7
+ try { const raw=env?.STRIPE_SHOP_LINKS??'null'; const c = JSON.parse(raw.startsWith('base64:') ? new TextDecoder().decode(Uint8Array.from(atob(raw.slice(7)),c=>c.charCodeAt(0))) : raw); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
8
8
  }
9
9
  export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
10
10
  export function linkSettings(shop) {
@@ -12,6 +12,7 @@ export function linkSettings(shop) {
12
12
  cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
13
13
  }
14
14
  export function fullerReason(shop) {
15
+ if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
15
16
  if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
16
17
  if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
17
18
  return null;
@@ -61,7 +61,7 @@ export const WAY_LINES = Object.freeze({
61
61
  export function shopPage(cat, shop, { origin = '', game = null, item = null, open = false, mode = null } = {}) {
62
62
  const studio = cat.studio?.name ?? 'Studio';
63
63
  const g = game ? (cat.games ?? []).find((x) => x.id === game) : null;
64
- const preview = !open && /^https?:\/\/(localhost|127\.0\.0\.1|\[::1\])(?::|\/|$)/.test(origin);
64
+ const preview = !open;
65
65
  const giving = Boolean(shop.giving);
66
66
  const previewItems = preview ? shop.items.map(i => `<article class="item"><h2>${esc(i.name)}</h2><span class="price">${esc(money(i.price === 'choose' ? i.min : i.price, shop.currency))}${i.interval ? ' / month' : ''}</span><p>${esc(i.blurb ?? '')}</p><button disabled>Preview only</button></article>`).join('') : '';
67
67
  const boot = { giving, preview, game: g ? g.id : null, item: ITEM_ID.test(String(item ?? '')) ? item : null, studio };
@@ -72,7 +72,7 @@ ${mode === 'test' ? '<p class="test">TEST MODE: no real money moves (Stripe test
72
72
  <p class="lead">${giving ? `Support ${esc(studio)} with a one-time or monthly gift. Gifts go directly to the organisation through its own Stripe account.` : `Things you can buy here, in real money, from ${esc(studio)} itself. Paid purchases open Stripe's checkout page. Guest purchases stay in this browser; keep its cookies or sign in to keep access.`} ${g ? `<a href="/${esc(g.id)}/">Back to ${esc(g.name)}</a>` : ''}</p>
73
73
  <noscript><p class="lead">This page needs JavaScript.</p></noscript>
74
74
  <div data-shop-age hidden></div>
75
- <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW — checkout is disabled. Connect Stripe to open.' : 'Connect the organisation’s Stripe account to open giving or sales.'}</p>${previewItems}</div>
75
+ <div class="items" data-shop-items><p>${open ? 'Reading the shop…' : preview ? 'PREVIEW — not taking payments yet. Checkout is disabled. Connect Stripe to open.' : 'Connect the organisation’s Stripe account to open giving or sales.'}</p>${previewItems}</div>
76
76
  ${giving && shop.giving.receiptText ? `<p class="note">${esc(shop.giving.receiptText)}</p>` : ''}
77
77
  <p class="msg" data-shop-msg role="status" aria-live="polite"></p>
78
78
  <p class="note">${giving ? `${esc(studio)} receives your gift through its own Stripe account. Stripe emails the payment receipt.` : `${esc(studio)} is the seller: it sets these prices, answers for refunds and receives the money through its own Stripe account. Homie takes nothing. ${refundWords(shop)} <a href="/shop/refunds/">refunds</a>. Stripe keeps your card and email and sends your receipt; ${esc(studio)} never sees them.`}</p>
@@ -207,6 +207,14 @@ export const SHOP_JS = String.raw`(function () {
207
207
  age.appendChild(sel); age.appendChild(ok);
208
208
  }
209
209
  }
210
+ (s.menus || []).forEach(function (menu, index) {
211
+ var button = el('button', {type:'button',class:'buy'}, s.menus.length > 1 ? 'Choose items on Stripe · Menu ' + (index+1) : 'Choose several items on Stripe');
212
+ button.onclick = function () {
213
+ button.disabled=true;
214
+ api('POST','/api/shop/buy',{lines:menu.items.map(function(item){return {item:item,quantity:1};}),game:boot.game||undefined}).then(function(r){if(!r.ok||!r.url)throw new Error(r.message||'Could not open menu');location.href=r.url;}).catch(function(e){button.disabled=false;say(e.message,true);});
215
+ };
216
+ list.appendChild(button);
217
+ });
210
218
  s.items.forEach(function (i) {
211
219
  var card = el('article', { class: 'item', id: 'item-' + i.id });
212
220
  card.appendChild(el('h2', null, i.name));
@@ -236,7 +244,7 @@ export const SHOP_JS = String.raw`(function () {
236
244
  var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
237
245
  add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
238
246
  if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
239
- else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
247
+ else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : boot.giving ? 'Confirm your gift on Stripe.' : 'Confirm quantity on Stripe, or use a menu above for several items.'));
240
248
  } else if (i.way === 'owned') {
241
249
  act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
242
250
  } else if (i.way === 'make-an-account') {
@@ -261,6 +269,14 @@ export const SHOP_JS = String.raw`(function () {
261
269
  list.appendChild(card);
262
270
  });
263
271
  if (s.nextCursor) { var next = el('button', { type: 'button' }, 'Next items'); next.onclick = function () { load(s.nextCursor); }; list.appendChild(next); }
272
+ if(s.portalLogin)list.appendChild(el('a',{href:s.portalLogin,target:'_blank',rel:'noopener',class:'plain'},'Manage monthly giving or membership on Stripe'));
273
+ if (!s.portalLogin && s.items.some(function(i) { return i.interval; })) api('GET', '/api/shop/subscriptions').then(function(r) {
274
+ (r.subscriptions || []).forEach(function(sub) {
275
+ var manage = el('button', { type: 'button', class: 'plain' }, 'Manage subscription on Stripe');
276
+ manage.onclick = function() { var tab = window.open('', '_blank'); api('POST', '/api/shop/portal', { subscription: sub.id }).then(function(result) { if (!result.ok || !result.url) throw new Error(result.message || 'Portal unavailable'); if (tab) tab.location.href = result.url; else location.href = result.url; }).catch(function(e) { if (tab) tab.close(); say(e.message, true); }); };
277
+ list.appendChild(manage);
278
+ });
279
+ }).catch(function() {});
264
280
  if (!s.items.length) list.appendChild(el('p', null, boot.giving ? 'No gift options are available right now.' : 'Nothing for sale here right now.'));
265
281
  if (boot.item) { var at = document.getElementById('item-' + boot.item); if (at && at.scrollIntoView) at.scrollIntoView({ block: 'center' }); }
266
282
  }
@@ -282,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
282
298
  (function poll() {
283
299
  tries += 1;
284
300
  api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
285
- if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; return; }
301
+ if (r.status === 'paid') { returnToPage(false); $('[data-thanks-title]').textContent = boot.giving ? 'Thank you for your gift' : 'It\'s yours'; $('[data-thanks-line]').textContent = boot.giving ? 'Your gift is confirmed. Your receipt comes from Stripe by email.' : 'Paid. It is on your account now, in every game of ' + boot.studio + ' that uses it.'; if (r.simulated) { $('[data-thanks-title]').textContent = 'Simulation confirmed'; $('[data-thanks-line]').textContent = 'This was an offline test. No money moved and no Stripe receipt was sent.'; } return; }
286
302
  if (tries < 40) setTimeout(poll, 1500);
287
303
  else $('[data-thanks-line]').textContent = boot.giving ? 'Stripe has not confirmed your gift yet. Check your email for its receipt.' : 'Stripe has not confirmed it yet. It will be on your account as soon as it does; your receipt comes by email.';
288
304
  }).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
@@ -485,13 +501,19 @@ export const SHOP_SHELL_JS = String.raw`(function () {
485
501
  if (!cfg || kids) return reply({ ok: true, open: false, kids: kids, screen: screen, owns: [] });
486
502
  return refresh().then(function () { reply({ ok: true, open: true, kids: false, screen: screen, owns: owns }); });
487
503
  }
504
+ if (m.op === 'catalog') {
505
+ if (kids) return reply({ ok: true, open: false, items: [] });
506
+ return api('GET', '/api/shop?game=' + encodeURIComponent(GAME) + '&server=' + encodeURIComponent(SERVER) + '&cursor=' + encodeURIComponent(typeof m.cursor === 'string' ? m.cursor : '')).then(function (r) {
507
+ reply({ ok: r.ok, open: r.open, currency: r.currency, currencyScale: r.currencyScale, items: r.items, nextCursor: r.nextCursor });
508
+ }).catch(function () { reply({ ok: false }); });
509
+ }
488
510
  if (m.op === 'owns') return refresh().then(function () { reply({ ok: true, owns: owns }); });
489
511
  if (m.op === 'open') { open(typeof m.item === 'string' ? m.item : null); return reply({ ok: true, shown: true }); }
490
512
  if (m.op === 'close') { close(); return reply({ ok: true }); }
491
513
  if (m.op === 'checkout') {
492
514
  if (!cfg || kids || screen) return reply({ ok: false, error: 'policy' });
493
515
  var checkoutTab = window.open('', '_blank');
494
- return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, game: GAME, server: SERVER }).then(function (r) {
516
+ return api('POST', '/api/shop/buy', { lines: m.lines, reference: m.reference, booking: m.booking, game: GAME, server: SERVER }).then(function (r) {
495
517
  if (r.ok && r.url) { leaveForCheckout(checkoutTab, r); watch(); } else if (checkoutTab) checkoutTab.close();
496
518
  reply(r);
497
519
  }).catch(function () { if (checkoutTab) checkoutTab.close(); reply({ ok: false }); });
@@ -49,6 +49,7 @@ export function amountError(amount, currency) {
49
49
 
50
50
  /** Checkout wording is shared by validation and the request path. */
51
51
  export function checkoutMessage(shop, studio = 'this studio', parent = false) {
52
+ if (shop.giving?.receiptText) return shop.giving.receiptText;
52
53
  return `${parent ? "This is for your child's account. " : ''}Delivered at once to the player's account on ${studio}. ${shop.policy.withdrawalAcknowledgement ? 'By paying you ask for it now, which ends the 14-day withdrawal right;' : 'Your statutory rights still apply.'} ${shop.refundDays === null ? (shop.policy.withdrawalAcknowledgement ? 'ask the studio about refunds.' : 'Ask the studio about refunds.') : `${shop.policy.refundUsedItems ? 'an item' : 'an unused item'} can still be refunded within ${shop.refundDays} days.`}`;
53
54
  }
54
55
 
@@ -157,7 +158,7 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
157
158
  if (why) err(`${at}.price`, why);
158
159
  else price = numeric(it.price);
159
160
  }
160
- if (it.interval !== undefined && (it.interval !== 'month' || chosen)) err(`${at}.interval`, 'monthly gifts need a fixed suggested amount');
161
+ if (it.interval !== undefined && it.interval !== 'month') err(`${at}.interval`, 'interval must be month');
161
162
  const charge = chosen ? min : price;
162
163
  if (currencyScale(currency) === 1000 && charge % 10) warnings.push({ at: `${at}.price`, message: 'This three-decimal amount uses the smallest minor unit. Confirm support with your Stripe payment method; Stripe decides acceptance (https://docs.stripe.com/currencies).' });
163
164
  if (charge > 0 && charge < minimumCharge(currency)) warnings.push({ at: `${at}.price`, message: `Stripe's minimum for settlement in ${currency.toUpperCase()} is ${money(minimumCharge(currency), currency)}. Your settlement currency and payment method determine the actual minimum at checkout.` });
@@ -286,7 +286,7 @@ export function forgetPlayerShop(env, player) {
286
286
  /** Grant an order's entitlements (idempotent on player, key and order). */
287
287
  export function grantStatements(env, order, item, now = Date.now(), line = { id: order.id + '_0', quantity: 1 }) {
288
288
  const starts = item.starts ? Math.max(now, Date.parse(item.starts)) : now;
289
- const ends = item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
289
+ const ends = item.interval ? starts : item.ends ? Date.parse(item.ends) : item.days !== undefined && item.days !== null ? starts + Math.round(item.days * DAY) : null;
290
290
  return (item.gives ?? []).flatMap((key) => [
291
291
  env.DB.prepare("INSERT INTO entitlements (player, key, item, order_id, starts_at, ends_at, state, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, 'active', NULL) ON CONFLICT(player, key, order_id) DO UPDATE SET starts_at = MIN(starts_at, excluded.starts_at), ends_at = CASE WHEN ends_at IS NULL OR excluded.ends_at IS NULL THEN NULL ELSE MAX(ends_at, excluded.ends_at) END").bind(order.player, key, item.id, order.id, starts, ends),
292
292
  env.DB.prepare("INSERT INTO shop_entitlement_lines (player, key, item, order_id, line_id, quantity, starts_at, ends_at, state) SELECT ?1, ?2, ?3, ?4, ?7, ?8, ?5, ?6, 'active' WHERE NOT EXISTS (SELECT 1 FROM entitlements WHERE player = ?1 AND key = ?2 AND order_id = ?4 AND state != 'active') ON CONFLICT DO NOTHING").bind(order.player, key, item.id, order.id, starts, ends, line.id, line.quantity),