@homie-rocks/studio 0.51.2 → 0.51.4
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.
- package/CHANGELOG.md +41 -0
- package/app-starters/gathering/app.json +2 -44
- package/app-starters/gathering/index.html +1 -1
- package/app-starters/gathering/src/view.ts +6 -19
- package/apps/APPS.md +55 -1
- package/bin/homie-studio.mjs +5 -1
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +32 -2
- package/guides/office/SKILL.md +24 -0
- package/guides/servers/SKILL.md +24 -0
- package/guides/shop/SKILL.md +8 -0
- package/lib/app-needs.mjs +17 -2
- package/lib/app-ready.mjs +1 -0
- package/lib/art-cli.mjs +2 -2
- package/lib/art-tools.mjs +2 -2
- package/lib/check.mjs +2 -0
- package/lib/decisions.mjs +9 -5
- package/lib/dev.mjs +3 -0
- package/lib/functions-build.mjs +8 -1
- package/lib/shop-links.mjs +4 -2
- package/lib/shop-local.mjs +31 -0
- package/lib/style-presets.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/shop/SHOP.md +6 -0
- package/worker/app-records.mjs +3 -3
- package/worker/arrival.mjs +5 -4
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/index.mjs +7 -0
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/pages.mjs +9 -3
- package/worker/shop-page.mjs +2 -2
- package/worker/shop-subscriptions.mjs +42 -0
- package/worker/site.mjs +9 -7
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Experimental MCP Events design sketch (2026-10-14 snapshot).
|
|
2
|
+
* Keep draft method names, envelopes, schemas and provisional codes here.
|
|
3
|
+
* https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/13e0a59749cde947374aaa8e3a65f0b424468d5a/docs/design-sketch-proposal.md
|
|
4
|
+
*/
|
|
5
|
+
export const eventsCapability={events:{listChanged:true}};
|
|
6
|
+
export const EVENTS=Object.freeze({list:'events/list',poll:'events/poll',stream:'events/stream',subscribe:'events/subscribe',unsubscribe:'events/unsubscribe'});
|
|
7
|
+
export const eventMethods=new Set(Object.values(EVENTS));
|
|
8
|
+
export const EVENT_ERRORS=Object.freeze({invalid:-32602,notFound:-32023,forbidden:-32024,exhausted:-32025,unsupported:-32026,callback:-32027});
|
|
9
|
+
const messages={[-32602]:'InvalidParams',[-32023]:'NotFound',[-32024]:'Forbidden',[-32025]:'ResourceExhausted',[-32026]:'Unsupported',[-32027]:'CallbackEndpointError'};
|
|
10
|
+
export class EventsError extends Error {
|
|
11
|
+
constructor(code,data){super(messages[code]);this.code=code;this.data=data;}
|
|
12
|
+
}
|
|
13
|
+
export const invalid=()=>{throw new EventsError(-32602);};
|
|
14
|
+
export function eventError(error){return error instanceof EventsError?{code:error.code,message:error.message,...(error.data?{data:error.data}:{})}:{code:-32603,message:'Internal error'};}
|
|
15
|
+
export function eventDescriptor(name,feed){return {name,description:feed.description??`Studio ${feed.event} events; untrusted data`,delivery:['poll','push','webhook'],inputSchema:{type:'object',properties:{},additionalProperties:false},payloadSchema:{type:'object'}};}
|
|
16
|
+
export function occurrence(name,event,cursor){return {eventId:event.id,name,timestamp:new Date(event.at).toISOString(),data:event.data,...(cursor===undefined?{}:{cursor})};}
|
|
17
|
+
export const cursorOf=(name,seq)=>'v1:'+name+':'+seq;
|
|
18
|
+
export function cursorPosition(name,cursor){if(cursor==null)return null;if(typeof cursor!=='string'||!cursor.startsWith('v1:'+name+':'))invalid();const value=cursor.slice(name.length+4);if(!/^(0|[1-9][0-9]*)$/.test(value)||!Number.isSafeInteger(Number(value)))invalid();return Number(value);}
|
|
19
|
+
export function validateEventParams(params,{list=false}={}){
|
|
20
|
+
if(!params||typeof params!=='object'||Array.isArray(params))invalid();
|
|
21
|
+
if(list){if(params.cursor!=null)invalid();return;}
|
|
22
|
+
if(typeof params.name!=='string'||!params.name)invalid();
|
|
23
|
+
if(params.arguments!==undefined&&(!params.arguments||typeof params.arguments!=='object'||Array.isArray(params.arguments)||Object.keys(params.arguments).length))invalid();
|
|
24
|
+
cursorPosition(params.name,params.cursor);
|
|
25
|
+
for(const key of ['maxAgeMs','maxEvents','ttlMs'])if(params[key]!==undefined&&!(key==='ttlMs'&¶ms[key]===null)&&(!Number.isSafeInteger(params[key])||params[key]<(key==='maxEvents'?1:0)))invalid();
|
|
26
|
+
}
|
|
27
|
+
export function callbackUrl(value){let url;try{url=new URL(value);}catch{invalid();}if(url.protocol!=='https:'||url.username||url.password||url.hash)invalid();return url.href;}
|
|
28
|
+
export function signingSecret(value){if(typeof value!=='string'||!/^whsec_(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))invalid();let raw;try{raw=atob(value.slice(6));}catch{invalid();}if(raw.length<24||raw.length>64||btoa(raw)!==value.slice(6))invalid();return value;}
|
|
29
|
+
export const notification=(type,params,id)=>({jsonrpc:'2.0',method:'notifications/events/'+type,params:{...params,...(id===undefined?{}:{_meta:{'io.modelcontextprotocol/subscriptionId':id}})}});
|
|
30
|
+
export const sseFrame=value=>new TextEncoder().encode('event: message\ndata: '+JSON.stringify(value)+'\n\n');
|
|
31
|
+
export const streamResult=id=>({jsonrpc:'2.0',id,result:{_meta:{}}});
|
|
32
|
+
export const control=(type,fields)=>({type,...fields});
|
|
33
|
+
export const subscriptionResult=(sub,cursor,truncated,deliveryStatus)=>({id:sub.id,refreshBefore:new Date(sub.expires).toISOString(),cursor,truncated,...(deliveryStatus?{deliveryStatus}:{})});
|
|
34
|
+
export const subscriptionHeaders=id=>({'X-MCP-Subscription-Id':id});
|
|
35
|
+
/** Internal pages keep stable names; draft field changes stay at the transport boundary. */
|
|
36
|
+
export function eventsResult(method,value){
|
|
37
|
+
if(method===EVENTS.list)return {events:value.events};
|
|
38
|
+
if(method===EVENTS.poll)return {events:value.events.map(({cursor,...event})=>event),cursor:value.cursor,truncated:value.truncated,hasMore:value.hasMore,nextPollMs:value.nextPollMs};
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
export const terminalEventError=code=>[EVENT_ERRORS.notFound,EVENT_ERRORS.forbidden,EVENT_ERRORS.unsupported].includes(code);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {rateLimit,callsPerMinute} from './mcp-store.mjs';
|
|
2
|
+
import {eventFeeds,readableFeed,matchesFeed,eventHub} from './event-resources.mjs';
|
|
3
|
+
import {currentCaller} from './mcp-tools.mjs';
|
|
4
|
+
import {signedWebhook,WebhookFailure} from './event-webhooks.mjs';
|
|
5
|
+
import {EVENTS,EVENT_ERRORS,eventsResult,EventsError,invalid,eventError,eventDescriptor,occurrence,cursorOf,cursorPosition,validateEventParams,callbackUrl,signingSecret,subscriptionResult,subscriptionHeaders,control} from './mcp-events-wire.mjs';
|
|
6
|
+
|
|
7
|
+
const prefix='mcp-event-hook:';
|
|
8
|
+
export const eventSource=feed=>JSON.stringify([feed.event,feed.app??null,feed.collection??null]);
|
|
9
|
+
export const hookFunction=id=>'@mcp-webhook/'+id;
|
|
10
|
+
const head=async env=>(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
|
|
11
|
+
export async function eventCaller(env,connection){
|
|
12
|
+
if(connection==='local-test-owner'&&env.HOMIE_LOCAL_TEST_SECRET)return {id:'local-test-owner',owner:true,connection};
|
|
13
|
+
try{return await currentCaller(env,{connection});}catch{throw new EventsError(EVENT_ERRORS.forbidden);}
|
|
14
|
+
}
|
|
15
|
+
export async function checkEvent(env,cat,caller,params){
|
|
16
|
+
validateEventParams(params);
|
|
17
|
+
const feeds=eventFeeds(env),feed=Object.hasOwn(feeds,params.name)?feeds[params.name]:null;
|
|
18
|
+
if(!feed)throw new EventsError(EVENT_ERRORS.notFound,{kind:'event'});
|
|
19
|
+
if(!await readableFeed(env,cat,caller,params.name))throw new EventsError(EVENT_ERRORS.forbidden);
|
|
20
|
+
return feed;
|
|
21
|
+
}
|
|
22
|
+
export async function listEvents(env,cat,caller,params={}){
|
|
23
|
+
validateEventParams(params,{list:true});const events=[];
|
|
24
|
+
for(const [name,feed] of Object.entries(eventFeeds(env)))if(await readableFeed(env,cat,caller,name))events.push(eventDescriptor(name,feed));
|
|
25
|
+
return {events};
|
|
26
|
+
}
|
|
27
|
+
export async function eventStart(env,params,now=Date.now()){
|
|
28
|
+
const through=await head(env);let after=cursorPosition(params.name,params.cursor),truncated=false;
|
|
29
|
+
if(after===null)return {after:through,through,truncated};
|
|
30
|
+
if(after>through){after=through;truncated=true;}
|
|
31
|
+
const first=(await env.DB.prepare('SELECT MIN(seq) seq FROM studio_events').first()).seq;
|
|
32
|
+
if(first!==null&&after<first-1){after=first-1;truncated=true;}
|
|
33
|
+
if(params.maxAgeMs!==undefined){const floor=(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events WHERE at<?1 AND seq<=?2').bind(now-params.maxAgeMs,through).first()).seq;if(floor>after){after=floor;truncated=true;}}
|
|
34
|
+
return {after,through,truncated};
|
|
35
|
+
}
|
|
36
|
+
export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
37
|
+
const feed=await checkEvent(env,cat,caller,params),start=await eventStart(env,params,now);
|
|
38
|
+
const max=Math.min(params.maxEvents??100,100);
|
|
39
|
+
// Filter before limiting: unrelated collections must not create a false hasMore
|
|
40
|
+
// or force the client through pages containing no matching occurrences.
|
|
41
|
+
const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
|
|
42
|
+
WHERE type=?1 AND seq>?2 AND seq<=?3
|
|
43
|
+
AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
|
|
44
|
+
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?feed.app:null,feed.collection??null,max+1).all()).results;
|
|
45
|
+
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
|
+
const events=page.map(row=>occurrence(params.name,{...row,data:JSON.parse(row.data)},cursorOf(params.name,row.seq)));
|
|
47
|
+
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
|
+
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
|
|
49
|
+
}
|
|
50
|
+
async function subId(caller,params,url){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([caller.id,url,params.name,{}])));return 'sub_'+Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');}
|
|
51
|
+
async function stored(env,id){const row=await env.DB.prepare('SELECT value FROM mcp_kv WHERE key=?1').bind(prefix+id).first();return row?JSON.parse(row.value):null;}
|
|
52
|
+
export async function removeEventHook(env,id){await env.DB.batch([env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(prefix+id),env.DB.prepare('DELETE FROM function_subscriptions WHERE name=?1').bind(hookFunction(id)),env.DB.prepare('DELETE FROM function_deliveries WHERE function=?1').bind(hookFunction(id))]);}
|
|
53
|
+
export function deliveryCategory(error){if(error.message==='challenge_failed')return 'challenge_failed';if(error.message==='Webhook timed out')return 'timeout';if(error.status>=500)return 'http_5xx';if(error.status)return 'http_4xx';return 'connection_refused';}
|
|
54
|
+
// Worker fetch cannot pin an independently resolved address with original TLS SNI.
|
|
55
|
+
// Restrict callbacks to exact owner-declared URLs, rechecked on every attempt.
|
|
56
|
+
// This also supplies the draft's server-configured endpoint-consent allowlist.
|
|
57
|
+
export function permittedCallback(env,name,url){
|
|
58
|
+
const feed=eventFeeds(env)[name];
|
|
59
|
+
return [...(feed?.receivers??[]),...(feed?.webhook?[feed.webhook.url]:[])].some(value=>callbackUrl(value)===url);
|
|
60
|
+
}
|
|
61
|
+
async function send(env,sub,body,id,options={}){
|
|
62
|
+
if(!permittedCallback(env,sub.name,sub.url))throw new WebhookFailure('Callback no longer allowed');
|
|
63
|
+
const secrets=[sub.secret,...(sub.previous&&sub.rotateUntil>(options.now??Date.now)()?[sub.previous]:[])];
|
|
64
|
+
return signedWebhook(sub.url,{id,body,secrets,headers:subscriptionHeaders(sub.id)},options);
|
|
65
|
+
}
|
|
66
|
+
async function watermark(env,sub){
|
|
67
|
+
const registry=await env.DB.prepare('SELECT cursor FROM function_subscriptions WHERE name=?1').bind(hookFunction(sub.id)).first();
|
|
68
|
+
const pending=await env.DB.prepare("SELECT MIN(e.seq) seq FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state!='complete'").bind(hookFunction(sub.id)).first();
|
|
69
|
+
return cursorOf(sub.name,Math.max(sub.start,Math.min(registry?.cursor??sub.start,pending.seq===null?Number.MAX_SAFE_INTEGER:pending.seq-1)));
|
|
70
|
+
}
|
|
71
|
+
export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
|
|
72
|
+
if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);
|
|
73
|
+
const feed=await checkEvent(env,cat,caller,params);
|
|
74
|
+
if(!params.delivery||typeof params.delivery!=='object')invalid();
|
|
75
|
+
if(params.delivery.mode!==undefined&¶ms.delivery.mode!=='webhook')throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:params.delivery.mode});
|
|
76
|
+
const url=callbackUrl(params.delivery.url),secret=signingSecret(params.delivery.secret);
|
|
77
|
+
if(!permittedCallback(env,params.name,url))throw new EventsError(EVENT_ERRORS.callback,{reason:'challenge_failed'});
|
|
78
|
+
const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
|
|
79
|
+
if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
80
|
+
if(previous&&!live)await removeEventHook(env,id);
|
|
81
|
+
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%'").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
|
|
82
|
+
const start=await eventStart(env,params,now());
|
|
83
|
+
const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
|
|
84
|
+
if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
|
|
85
|
+
await env.DB.batch([
|
|
86
|
+
env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(prefix+id,JSON.stringify(sub)),
|
|
87
|
+
env.DB.prepare('INSERT OR IGNORE INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,?3,?4)').bind(hookFunction(id),feed.event,sub.start,now()),
|
|
88
|
+
]);
|
|
89
|
+
const latest=await env.DB.prepare("SELECT error,completed FROM function_deliveries WHERE function=?1 ORDER BY CASE WHEN state!='complete' AND error IS NOT NULL THEN 0 ELSE 1 END, rowid DESC LIMIT 1").bind(hookFunction(id)).first();
|
|
90
|
+
return subscriptionResult(sub,await watermark(env,sub),live?false:start.truncated,{active:true,lastError:latest?.error??null,lastDeliveryAt:latest?.completed?new Date(latest.completed).toISOString():null});
|
|
91
|
+
}
|
|
92
|
+
export async function unsubscribeEvents(env,caller,params){
|
|
93
|
+
if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);validateEventParams(params);
|
|
94
|
+
const id=await subId(caller,params,callbackUrl(params.delivery?.url)),sub=await stored(env,id);
|
|
95
|
+
if(!sub||sub.expires<=Date.now())throw new EventsError(EVENT_ERRORS.notFound,{kind:'subscription'});
|
|
96
|
+
await removeEventHook(env,id);return {};
|
|
97
|
+
}
|
|
98
|
+
/** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
|
|
99
|
+
export async function eventHookSubscriptions(env,cat,options={}){
|
|
100
|
+
const now=options.now??Date.now,definitions=[];let after='';
|
|
101
|
+
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
102
|
+
for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
|
|
103
|
+
let feed;
|
|
104
|
+
try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
105
|
+
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
|
|
106
|
+
definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
|
|
107
|
+
const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
|
|
108
|
+
try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
|
|
109
|
+
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
|
|
110
|
+
try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
|
|
111
|
+
catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
|
|
112
|
+
}});
|
|
113
|
+
}
|
|
114
|
+
if(rows.length<100)break;after=rows.at(-1).key;
|
|
115
|
+
}return definitions;
|
|
116
|
+
}
|
|
117
|
+
export async function eventsRequest(request,env,ctx,cat,caller,body){
|
|
118
|
+
const params=body.params??{};
|
|
119
|
+
try{
|
|
120
|
+
if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))throw new EventsError(EVENT_ERRORS.exhausted,{limit:'requests'});
|
|
121
|
+
if(body.jsonrpc!=='2.0'||!['string','number'].includes(typeof body.id))invalid();
|
|
122
|
+
if(body.method===EVENTS.stream){
|
|
123
|
+
await checkEvent(env,cat,caller,params);
|
|
124
|
+
if(!env.LOBBY)throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:'push'});
|
|
125
|
+
const connection=caller.connection??'local-test-owner';
|
|
126
|
+
return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'extension-stream',connection,cat,id:body.id,params})});
|
|
127
|
+
}
|
|
128
|
+
const result=body.method===EVENTS.list?await listEvents(env,cat,caller,params):body.method===EVENTS.poll?await pollEvents(env,cat,caller,params):body.method===EVENTS.subscribe?await subscribeEvents(env,cat,caller,params):await unsubscribeEvents(env,caller,params);
|
|
129
|
+
if(body.method===EVENTS.subscribe){const work=import('./functions.mjs').then(m=>m.runFunctions(env,cat,new URL(request.url).origin));if(ctx?.waitUntil)ctx.waitUntil(work);else await work;}
|
|
130
|
+
return Response.json({jsonrpc:'2.0',id:body.id,result:eventsResult(body.method,result)},{headers:{'cache-control':'no-store'}});
|
|
131
|
+
}catch(error){return Response.json({jsonrpc:'2.0',id:body.id??null,error:eventError(error)},{headers:{'cache-control':'no-store'}});}
|
|
132
|
+
}
|
package/worker/mcp.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
|
|
2
|
+
import {eventsRequest} from './mcp-events.mjs';
|
|
1
3
|
function freshErrorMessage(error,env,caller) {
|
|
2
4
|
let message=String(error?.message??error);
|
|
3
5
|
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]');
|
|
@@ -50,6 +52,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
50
52
|
}
|
|
51
53
|
if(request.method==='POST') {
|
|
52
54
|
const body=await request.clone().json().catch(()=>null);
|
|
55
|
+
if(eventMethods.has(body?.method))return eventsRequest(request,env,ctx,cat,caller,body);
|
|
53
56
|
if(body?.method==='tools/call') {
|
|
54
57
|
const tool=tools.find(t=>t.name===body.params?.name);
|
|
55
58
|
const schema=tool?.price?{...tool.inputSchema,properties:{...tool.inputSchema.properties,_payment:paymentInputSchema}}:tool?.inputSchema;
|
|
@@ -71,7 +74,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
71
74
|
};
|
|
72
75
|
const server=async()=>{
|
|
73
76
|
const capabilities=await selling.machineCapabilities(env);
|
|
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']}}:{}}}}});
|
|
77
|
+
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},...eventsCapability}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
75
78
|
const dispatch=new Map();
|
|
76
79
|
for(const name of Object.keys(eventFeeds(env))) {
|
|
77
80
|
if(!await readableFeed(env,cat,caller,name))continue;
|
package/worker/pages.mjs
CHANGED
|
@@ -125,7 +125,7 @@ export function playPage(cat, g, { embed = false, origin = '', preview = false,
|
|
|
125
125
|
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
126
126
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
127
127
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
128
|
-
const places = sharePlaces(g.screen?.share);
|
|
128
|
+
const places = sharePlaces(g.screen?.share ?? (g.kind === 'app' ? 'top-right' : undefined));
|
|
129
129
|
// The phone's glass belongs to the game: no page pan, pinch-zoom, text selection or callout under a thumb
|
|
130
130
|
// (a pinch between a stick thumb and a button thumb made the browser cancel both touches).
|
|
131
131
|
// A game with an art direction (games.json `ui`, from its style.json): the pills wear its paper and text, like its HUD.
|
|
@@ -448,6 +448,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
448
448
|
place();
|
|
449
449
|
addEventListener('resize', place);
|
|
450
450
|
function labelOf(room) {
|
|
451
|
+
if(boot.kind === 'app') return boot.name;
|
|
451
452
|
var m = /^pub-(\d+)$/.exec(room);
|
|
452
453
|
if (m) return 'Room ' + m[1];
|
|
453
454
|
// A room of this page's server (s-<server>-<n>) is "Room <n>" too: the server pill beside the button names it.
|
|
@@ -816,6 +817,11 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
816
817
|
}
|
|
817
818
|
|
|
818
819
|
function paint() {
|
|
820
|
+
if(boot.kind === 'app') {
|
|
821
|
+
say(state.link?.state === 'reconnecting' ? 'Reconnecting…' : 'Connected');
|
|
822
|
+
var count=ui && ui.querySelector('[data-room-count]'); if(count)count.hidden=true;
|
|
823
|
+
serverUi(); return;
|
|
824
|
+
}
|
|
819
825
|
var f = state.facts;
|
|
820
826
|
var n = f && f.counts ? f.counts.players : null;
|
|
821
827
|
var bits = [];
|
|
@@ -861,7 +867,7 @@ const SHELL_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
861
867
|
function serverUi() {
|
|
862
868
|
if (!sToggle || !sSheet) return;
|
|
863
869
|
var f = state.facts; var pol = f && f.policy ? f.policy : null;
|
|
864
|
-
var show = Boolean(state.server) || (hasAi() && readsDial());
|
|
870
|
+
var show = boot.kind === 'app' ? Boolean(state.server && state.server.id !== 'public') : Boolean(state.server) || (hasAi() && readsDial());
|
|
865
871
|
sToggle.hidden = !show;
|
|
866
872
|
if (!show) return;
|
|
867
873
|
var name = state.server ? state.server.name : 'Quick play';
|
|
@@ -1300,7 +1306,7 @@ const WATCH_JS = String.raw`${BUILD_RETRY_JS}(function () {
|
|
|
1300
1306
|
}
|
|
1301
1307
|
sizeUp();
|
|
1302
1308
|
addEventListener('resize', sizeUp);
|
|
1303
|
-
function labelOf(room) { var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
|
|
1309
|
+
function labelOf(room) { if(boot.kind === 'app') return boot.name; var m = /^pub-(\d+)$/.exec(room || ''); return m ? 'Room ' + m[1] : (room || ''); }
|
|
1304
1310
|
function el(tag, cls, text) { var e = document.createElement(tag); if (cls) e.className = cls; if (text !== undefined && text !== null) e.textContent = String(text); return e; }
|
|
1305
1311
|
function svg(path) { var s = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); s.setAttribute('viewBox', '0 0 24 24'); s.setAttribute('aria-hidden', 'true'); s.setAttribute('fill', 'none'); s.setAttribute('stroke', 'currentColor'); s.setAttribute('stroke-width', '2'); s.setAttribute('stroke-linecap', 'round'); s.setAttribute('stroke-linejoin', 'round'); s.innerHTML = path; return s; }
|
|
1306
1312
|
var AUTO_ICON = '<path d="M12 3v4M12 17v4M3 12h4M17 12h4M6 6l2.5 2.5M15.5 15.5 18 18M6 18l2.5-2.5M15.5 8.5 18 6"/>';
|
package/worker/shop-page.mjs
CHANGED
|
@@ -90,7 +90,7 @@ export function thanksPage(cat, shop, { session = null, game = null } = {}) {
|
|
|
90
90
|
<p class="kicker">${esc(studio)}</p>
|
|
91
91
|
<h1 data-thanks-title>Thank you</h1>
|
|
92
92
|
<p class="lead" data-thanks-line>Stripe is confirming the payment. ${shop.giving ? 'Thank you for supporting our work.' : 'This page says when it is yours (a few seconds).'}</p>
|
|
93
|
-
<p>${g ? `<a class="btn" href="/${esc(g.id)}
|
|
93
|
+
<p>${g ? `<a class="btn" href="/${esc(g.id)}/${g.kind === 'app' ? 'open' : 'play'}">Back to ${esc(g.name)}</a>` : `<a class="btn" href="/">Back to ${esc(studio)}</a>`} <a href="/account/">Your account</a></p>
|
|
94
94
|
<p class="note">Your receipt comes from Stripe by email. ${shop.giving ? esc(shop.giving.receiptText ?? '') : refundWords(shop)}</p>
|
|
95
95
|
</section>
|
|
96
96
|
<script type="application/json" id="shop-boot">${JSON.stringify(boot).replace(/</g, '\\u003c')}</script>
|
|
@@ -298,7 +298,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
298
298
|
(function poll() {
|
|
299
299
|
tries += 1;
|
|
300
300
|
api('GET', '/api/shop/order?session=' + encodeURIComponent(boot.thanks)).then(function (r) {
|
|
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.
|
|
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.' : '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; }
|
|
302
302
|
if (tries < 40) setTimeout(poll, 1500);
|
|
303
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.';
|
|
304
304
|
}).catch(function () { if (tries < 40) setTimeout(poll, 3000); });
|
|
@@ -31,6 +31,8 @@ export async function bindSubscription(env, order, session, shop) {
|
|
|
31
31
|
env.DB.prepare(`INSERT OR IGNORE INTO shop_subscription_events(id,subscription,type,invoice,amount,currency,at)
|
|
32
32
|
VALUES(?1,?2,'subscription.started',NULL,?3,?4,?5)`).bind(`started:${subscription}`,subscription,order.amount,order.currency,order.paid_at),
|
|
33
33
|
]);
|
|
34
|
+
const firstInvoice=idOf(session.invoice);
|
|
35
|
+
if(firstInvoice)await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO NOTHING').bind(`subscription-first-invoice:${subscription}`,firstInvoice).run();
|
|
34
36
|
await reconcileMembership(env, subscription, shop);
|
|
35
37
|
if(!hasStripeKey(env)) {
|
|
36
38
|
const invoice=typeof session.invoice==='string'?session.invoice:session.invoice?.id;
|
|
@@ -70,6 +72,7 @@ export async function subscriptionEvent(env, event, shop) {
|
|
|
70
72
|
else if (event.type === 'invoice.paid') { if (obj.billing_reason === 'subscription_create') return 'initial'; type='subscription.renewed'; state='active'; }
|
|
71
73
|
else if (event.type === 'invoice.payment_failed') { type='subscription.failed'; state='past_due'; }
|
|
72
74
|
else { return 'updated'; } // Scheduling cancellation is not cancellation; Stripe sends deleted when it ends.
|
|
75
|
+
if(type==='subscription.renewed')await recordRenewal(env,row,obj,at);
|
|
73
76
|
const eventId=type === 'subscription.renewed' ? `paid:${obj.id}` : event.id;
|
|
74
77
|
await env.DB.batch([
|
|
75
78
|
env.DB.prepare(`UPDATE shop_subscriptions SET state=?2,event_at=?3 WHERE id=?1 AND state!='cancelled' AND event_at<=?3 AND ?4=0`).bind(subscription,state,at,hasStripeKey(env)?1:0),
|
|
@@ -196,3 +199,42 @@ async function invoiceMembership(env,subscription,invoice) {
|
|
|
196
199
|
}
|
|
197
200
|
if(writes.length)await env.DB.batch(writes);
|
|
198
201
|
}
|
|
202
|
+
|
|
203
|
+
/** The invoice is the financial identity; delivery retries never count twice.
|
|
204
|
+
* Access stays on the original subscription; renewal orders are payment receipts. */
|
|
205
|
+
async function recordRenewal(env, subscription, invoice, at) {
|
|
206
|
+
if(invoice.status!=='paid')return;
|
|
207
|
+
if(await env.DB.prepare('SELECT id FROM shop_subscription_events WHERE id=?1').bind(`paid:${invoice.id}`).first())return;
|
|
208
|
+
const first=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`subscription-first-invoice:${subscription.id}`).first();
|
|
209
|
+
if(first?.value===invoice.id || invoice.billing_reason!=='subscription_cycle')return;
|
|
210
|
+
if(hasStripeKey(env)) {
|
|
211
|
+
const invoiceId=invoice.id;
|
|
212
|
+
invoice=await stripeCall(env,'GET',`/v1/invoices/${invoiceId}`,{expand:['lines.data.price.product']});
|
|
213
|
+
if(invoice.id!==invoiceId || idOf(invoice.parent?.subscription_details?.subscription??invoice.subscription)!==subscription.id || idOf(invoice.customer)!==subscription.customer || invoice.livemode!==(subscription.mode==='live'))throw new Error('Renewal invoice identity mismatch');
|
|
214
|
+
if(invoice.lines?.has_more)invoice.lines=await allPages(env,`/v1/invoices/${invoiceId}/lines`,{expand:['data.price.product']});
|
|
215
|
+
}
|
|
216
|
+
const original=await env.DB.prepare('SELECT * FROM shop_orders WHERE id=?1').bind(subscription.order_id).first();
|
|
217
|
+
const lines=(await env.DB.prepare('SELECT * FROM shop_order_lines WHERE order_id=?1').bind(original.id).all()).results;
|
|
218
|
+
// A subscription without shop lines has no shop fulfilment to repeat.
|
|
219
|
+
if(!lines.length)return;
|
|
220
|
+
if(invoice.status!=='paid' || invoice.lines?.has_more || invoice.currency!==original.currency || !Number.isSafeInteger(invoice.amount_paid) || invoice.amount_paid<0)throw new Error('Complete paid renewal invoice required');
|
|
221
|
+
const paidLines=(invoice.lines?.data??[]).map(l=>{
|
|
222
|
+
const price=idOf(l.price ?? l.pricing?.price_details?.price);
|
|
223
|
+
const product=l.price?.product;
|
|
224
|
+
const source=lines.find(s=>{const snapshot=JSON.parse(s.snapshot??'{}');return snapshot.stripePrice===price || hasStripeKey(env) && (product?.metadata?.line===s.id || [product?.metadata?.homie_item,product?.metadata?.item].includes(s.item) || snapshot.stripeProduct && snapshot.stripeProduct===idOf(product));});
|
|
225
|
+
if(!source || !Number.isSafeInteger(l.quantity) || l.quantity<1 || !Number.isSafeInteger(l.amount) || l.amount<0)throw new Error('Unknown renewal invoice line');
|
|
226
|
+
return {...source,quantity:l.quantity,amount:l.amount};
|
|
227
|
+
});
|
|
228
|
+
if(!paidLines.length)throw new Error('Renewal invoice has no lines');
|
|
229
|
+
const amount=paidLines.reduce((n,l)=>n+l.amount,0),total=invoice.total??invoice.amount_paid;
|
|
230
|
+
const id=`renewal:${invoice.id}`;
|
|
231
|
+
const writes=[env.DB.prepare(`INSERT OR IGNORE INTO shop_orders(id,player,item,game,amount,currency,total,till,mode,status,created_at,updated_at)
|
|
232
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,'stripe',?8,'started',?9,?9)`)
|
|
233
|
+
.bind(id,original.player,original.item,original.game,amount,original.currency,total,original.mode,at),
|
|
234
|
+
env.DB.prepare(`INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2`).bind(`shop-reference:${id}`,`shop-reference:${original.id}`),
|
|
235
|
+
...paidLines.map((l,i)=>env.DB.prepare(`INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,total,snapshot,status)
|
|
236
|
+
VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'paid')`).bind(`${id}_${i}`,id,i,l.item,l.quantity,l.amount/l.quantity,l.amount,l.snapshot)),
|
|
237
|
+
env.DB.prepare("UPDATE shop_orders SET paid_at=?2,status='paid',payment=?3,tax=?4 WHERE id=?1 AND paid_at IS NULL")
|
|
238
|
+
.bind(id,at,idOf(invoice.payment_intent)??null,total-amount)];
|
|
239
|
+
await env.DB.batch(writes);
|
|
240
|
+
}
|
package/worker/site.mjs
CHANGED
|
@@ -556,14 +556,16 @@ const studioName = (cat) => cat.studio?.name ?? 'Studio';
|
|
|
556
556
|
export function sectionsOf(cat) {
|
|
557
557
|
const games = (cat.games ?? []).some((g) => g.kind !== 'app');
|
|
558
558
|
const apps = (cat.games ?? []).some((g) => g.kind === 'app');
|
|
559
|
+
const ownMenu = ['business','cause'].includes(cat.studio?.kind);
|
|
559
560
|
return [
|
|
560
|
-
...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
|
|
561
|
+
...(cat.site?.pages ?? []).filter(p => /^\/[a-z0-9][a-z0-9._-]*\/$/.test(p) && !['/shop/', '/apps/', '/games/', '/rooms/', '/posts/', '/music/', '/videos/', '/lounge/'].includes(p)).map(p => ({ key: p, href: p, label: (ownMenu && (cat.games??[]).find(g=>g.kind==='app' && `/${g.id}/`===p)?.name) || p.slice(1, -1).replaceAll('-', ' ').replace(/^./, c => c.toUpperCase()) })),
|
|
561
562
|
cat.shop && { key: 'shop', href: '/shop/', label: cat.shop.giving ? 'Give' : 'Shop' },
|
|
562
|
-
|
|
563
|
+
...((ownMenu ? cat.games ?? [] : []).filter(g=>g.kind==='app' && !(cat.site?.pages??[]).includes(`/${g.id}/`)).map(g=>({key:g.id,href:`/${g.id}/`,label:g.name}))),
|
|
564
|
+
apps && !ownMenu && { key: 'apps', href: '/apps/', label: 'Apps' },
|
|
563
565
|
games && { key: 'games', href: '/games/', label: 'Games' },
|
|
564
566
|
(cat.songs ?? []).length > 0 && { key: 'music', href: '/music/', label: 'Music' },
|
|
565
567
|
(cat.videos ?? []).length > 0 && { key: 'videos', href: '/videos/', label: 'Videos' },
|
|
566
|
-
(games || apps) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
568
|
+
(games || (apps && !ownMenu)) && { key: 'rooms', href: '/rooms/', label: 'Rooms' },
|
|
567
569
|
(cat.posts ?? []).length > 0 && { key: 'posts', href: '/posts/', label: 'Posts' },
|
|
568
570
|
// The Lounge (0.29.0): the studio's own community room, when studio.json turns it on.
|
|
569
571
|
cat.studio?.lounge && { key: 'lounge', href: '/lounge/', label: cat.studio.lounge.tab ?? 'Lounge' },
|
|
@@ -732,7 +734,7 @@ function gameCard(g, playing = 0) {
|
|
|
732
734
|
<h3><a href="/${esc(g.id)}/">${esc(g.name)}</a></h3>
|
|
733
735
|
<p>${esc(g.landing?.pitch ?? g.blurb)}</p>
|
|
734
736
|
<div class="meta"><span>${esc(g.kind === 'app' ? 'One screen · live together' : playersText(g))}</span>${minutes(g.roundSeconds) ? `<span>${esc(minutes(g.roundSeconds))}</span>` : ''}<span>phone, computer or TV</span></div>
|
|
735
|
-
<div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
|
|
737
|
+
<div class="acts"><a class="btn" href="${esc(openPath(g))}" data-play>${PLAY_ICON}${esc(g.kind === 'app' ? g.landing?.action ?? `Open ${g.name}` : appWords(g).open)}</a><a class="ghost" href="/${esc(g.id)}/">About</a></div>
|
|
736
738
|
</div>
|
|
737
739
|
</article>`;
|
|
738
740
|
}
|
|
@@ -836,7 +838,7 @@ export function homeHero(cat, f, { playing = 0 } = {}) {
|
|
|
836
838
|
<p class="kicker">${esc(name)}<span class="k-opt"> · ${esc(tagline || (f.kind === 'app' ? 'featured app' : 'featured game'))}</span></p>
|
|
837
839
|
<h1 class="title ${titleClass(f.name)}" id="hero-title">${esc(f.name)}</h1>
|
|
838
840
|
<p class="line">${esc(f.landing?.pitch ?? f.blurb)}</p>
|
|
839
|
-
<div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(
|
|
841
|
+
<div class="row"><a class="play" href="${esc(openPath(f))}" data-play>${PLAY_ICON}<span>${f.kind === 'app' ? esc(f.landing?.action ?? `Open ${f.name}`) : 'Play now — free'}</span></a>${liveLine(f, playing, { idle: f.kind === 'app' ? 'One screen, together' : 'Bots hold every empty seat · press Play and you are in a round' })}</div>
|
|
840
842
|
<a class="also" href="/${esc(f.id)}/">${icon('arrow')}<span>About ${esc(f.name)}</span></a>${games.length > 1 ? `<a class="also" href="/${f.kind === 'app' ? 'apps' : 'games'}/">${icon('games')}<span>All ${games.length} ${f.kind === 'app' ? 'apps' : 'games'}</span></a>` : ''}
|
|
841
843
|
</div>
|
|
842
844
|
</section>`;
|
|
@@ -1199,7 +1201,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1199
1201
|
${lineage ? `<p class="based-on">Based on ${ofWhat}</p>` : ''}
|
|
1200
1202
|
<p class="line">${esc(L.pitch ?? g.blurb)}</p>
|
|
1201
1203
|
<div class="row">
|
|
1202
|
-
<a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(
|
|
1204
|
+
<a class="play" href="${esc(openPath(g))}" data-play>${PLAY_ICON}<span>${app ? esc(L.action ?? `Open ${g.name}`) : 'Play now — free'}</span></a>
|
|
1203
1205
|
${liveLine(g, playing, { idle: app ? 'One screen, live together' : `Bots hold every empty seat · one tap and you are in` })}
|
|
1204
1206
|
</div>
|
|
1205
1207
|
${tvOn ? `<a class="also" href="#anywhere">${icon('tv')}<span>Or put it on the big screen</span></a>` : ''}${playing > 0 && rooms.length && watchOf(g) !== 'off' ? `<a class="also" href="/${esc(g.id)}/watch">${icon('eye')}<span>Watch a live room</span></a>` : ''}${trailer ? `<a class="also" href="/videos/${esc(trailer.slug)}/">${icon('videos')}<span>Watch the trailer</span></a>` : ''}${g.saves ? `<a class="also" href="/account/?next=${encodeURIComponent(`/${g.id}/play`)}" data-account>${icon('spark')}<span>Your progress follows you: sign in</span></a>` : ''}
|
|
@@ -1270,7 +1272,7 @@ export function gameLanding(cat, g, { origin = '', rooms = [], playing = 0, week
|
|
|
1270
1272
|
head: `${h.tallImage || h.wideImage ? `<link rel="preload" as="image" href="${esc(h.tallImage ?? h.wideImage)}"${h.tallImage && h.wideImage ? ' media="(max-aspect-ratio: 3/4)"' : ''}>${h.tallImage && h.wideImage ? `<link rel="preload" as="image" href="${esc(h.wideImage)}" media="(min-aspect-ratio: 3/4)">` : ''}` : ''}`,
|
|
1271
1273
|
// A game that is not public yet (private, or an invite-only beta) is never indexed, and offers nothing.
|
|
1272
1274
|
ld: landingLd(cat, g, origin, { listed, shop }), ...(listed ? {} : { extraHeaders: { 'x-robots-tag': 'noindex' } }),
|
|
1273
|
-
main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(
|
|
1275
|
+
main: `${hero}${app ? `<section class="band"><div class="band-in"><h2>One screen. Your place in it.</h2><p>${esc(L.about ?? g.blurb)}</p><div class="keys"><a class="btn" href="${esc(openPath(g))}">${esc(L.action ?? `Open ${g.name}`)}</a><a class="ghost" href="/${esc(g.id)}/tv">Wall screen</a></div>${qr ? `<div class="scan"><span class="qr">${qr}</span><p>${esc(words.join)}</p></div>` : ''}</div></section>` : ways + liveBand}${servers ? serversBand(g, servers) : ''}${app ? '' : howBand}${shotsBand}${band(partial(cat, 'game', vars))}${band(partial(cat, `game-${g.id}`, vars))}${creditsBand}`,
|
|
1274
1276
|
});
|
|
1275
1277
|
}
|
|
1276
1278
|
|
package/worker/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.4';
|