@homie-rocks/studio 0.50.6 → 0.50.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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,12 @@
1
+ import {playerProperties} from './embed.mjs';
2
+ import {appRole} from './app-format.mjs';
3
+ import {esc} from './site.mjs';
4
+ import {APP_SHELL_JS} from './app-shell.mjs';
5
+ /** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
6
+ export function recordsPage(meta,url,cat={}) {
7
+ 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');
8
+ const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
9
+ const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv')};
10
+ const source=`/${meta.id}/__game/?${params}`;
11
+ 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>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.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_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'}});
12
+ }
@@ -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>
package/worker/shop.mjs CHANGED
@@ -225,6 +225,7 @@ export async function shopRoutes(request, env, ctx, url, { catalogueOf }) {
225
225
  if (path === '/api/player/owns') return json({ ok: true, owns: [], open: false });
226
226
  if (path.startsWith('/api/')) return json({ ok: true, open: false, items: [], missing: [shop ? 'kids-studio' : 'shop.json'] });
227
227
  // No shop.json: /shop/ is the studio's own to use (a page in site/pages, say). The policy can close a kids studio's shop.
228
+ if(!shop && path==='/shop/' && read && !cat.site?.pages?.includes('/shop/')) return shopPage(cat,checkShop({}).shop,{origin:url.origin,open:false});
228
229
  return shop ? notFoundPage('This studio sells nothing.', cat) : null;
229
230
  }
230
231
  const ready = await readiness(env, shop);
@@ -0,0 +1,36 @@
1
+ export const TOOL_DATABASE_MIGRATION_FILE='0024_tool_data_versions.sql';
2
+ export const TOOL_DATABASE_MIGRATION='ALTER TABLE tool_data ADD COLUMN version INTEGER NOT NULL DEFAULT 1;\n';
3
+ /** Each mutation is one SQLite statement; callers never hold a JS lock across Workers. */
4
+ export function toolDatabase(DB,namespace) {
5
+ const key=k=>{if(typeof k!=='string'||!k.length||k.length>128)throw new Error('Key must contain 1–128 characters');return k;};
6
+ const decode=r=>r?{value:JSON.parse(r.value),version:r.version}:null;
7
+ const getVersioned=async k=>decode(await DB.prepare('SELECT value,version FROM tool_data WHERE namespace=?1 AND key=?2').bind(namespace,key(k)).first());
8
+ return Object.freeze({
9
+ async get(k){return (await getVersioned(k))?.value??null;},
10
+ getVersioned,
11
+ async put(k,value,{version}={}) {
12
+ const text=JSON.stringify(value);if(text===undefined)throw new Error('Value must be JSON');
13
+ if(version!==undefined&&(!Number.isSafeInteger(version)||version<0))throw new Error('Version must be a nonnegative integer (0 creates)');
14
+ const stmt=version===undefined
15
+ ? DB.prepare('INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) ON CONFLICT(namespace,key) DO UPDATE SET value=excluded.value,version=tool_data.version+1 RETURNING version').bind(namespace,key(k),text)
16
+ : version===0 ? DB.prepare('INSERT OR IGNORE INTO tool_data(namespace,key,value) VALUES(?1,?2,?3) RETURNING version').bind(namespace,key(k),text)
17
+ : DB.prepare('UPDATE tool_data SET value=?3,version=version+1 WHERE namespace=?1 AND key=?2 AND version=?4 RETURNING version').bind(namespace,key(k),text,version);
18
+ const row=await stmt.first();return row?{ok:true,version:row.version}:{ok:false,conflict:true};
19
+ },
20
+ async increment(k,amount=1) {
21
+ if(!Number.isFinite(amount))throw new Error('Increment must be finite');
22
+ const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json(?3)) ON CONFLICT(namespace,key) DO UPDATE SET value=json(json_extract(tool_data.value,'$')+?3),version=tool_data.version+1 WHERE json_type(tool_data.value) IN ('integer','real') RETURNING value,version`).bind(namespace,key(k),amount).first();
23
+ if(!row)throw new Error('Increment requires a numeric value');return decode(row);
24
+ },
25
+ async append(k,value) {
26
+ const text=JSON.stringify(value);if(text===undefined)throw new Error('Append requires JSON');
27
+ const row=await DB.prepare(`INSERT INTO tool_data(namespace,key,value) VALUES(?1,?2,json_array(json(?3))) ON CONFLICT(namespace,key) DO UPDATE SET value=json_insert(tool_data.value,'$[#]',json(?3)),version=tool_data.version+1 WHERE json_type(tool_data.value)='array' RETURNING value,version`).bind(namespace,key(k),text).first();
28
+ if(!row)throw new Error('Append requires an array value');return decode(row);
29
+ },
30
+ async list({prefix='',after='',limit=100}={}) {
31
+ if(typeof prefix!=='string'||typeof after!=='string'||!Number.isSafeInteger(limit)||limit<1)throw new Error('List requires a string prefix/cursor and positive page size');
32
+ const rows=(await DB.prepare('SELECT key,value,version FROM tool_data WHERE namespace=?1 AND substr(key,1,?2)=?3 AND key>?4 ORDER BY key LIMIT ?5').bind(namespace,prefix.length,prefix,after,limit+1).all()).results;
33
+ return {items:rows.slice(0,limit).map(r=>({key:r.key,...decode(r)})),next:rows.length>limit?rows[limit-1].key:null};
34
+ },
35
+ });
36
+ }
@@ -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.50.6';
2
+ export const STUDIO_VERSION_TAG = '0.50.7';