@homie-rocks/studio 0.51.7 → 0.52.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.
- package/CHANGELOG.md +54 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/bin/homie-studio.mjs +9 -4
- package/docs/toolkit-place-patterns.md +68 -0
- package/functions/FUNCTIONS.md +27 -0
- package/functions/functions.ts +4 -0
- package/guides/app/PLACES.md +123 -0
- package/guides/app/SKILL.md +11 -0
- package/guides/app/examples/reserve.mjs +13 -0
- package/guides/functions/SKILL.md +2 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/guides/studio-setup/SKILL.md +4 -0
- package/lib/app-needs.mjs +41 -6
- package/lib/app-ready.mjs +31 -0
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +13 -10
- package/lib/cloudflare.mjs +2 -2
- package/lib/connector-token.mjs +30 -0
- package/lib/dev-restart.mjs +25 -0
- package/lib/dev.mjs +6 -1
- package/lib/functions-build.mjs +3 -0
- package/lib/mcp-tools.mjs +2 -2
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +4 -4
- package/lib/shop-mcp.mjs +82 -14
- package/lib/stripe-connect.mjs +9 -4
- package/lib/template-history.json +84 -0
- package/lib/upgrade-places.mjs +49 -0
- package/lib/upgrade.mjs +2 -1
- package/lib/worker-config.mjs +6 -3
- package/links/links.d.mts +5 -0
- package/links/links.d.ts +2 -0
- package/links/links.mjs +2 -0
- package/links/time.mjs +20 -0
- package/netplay/netplay.ts +3 -0
- package/package.json +1 -1
- package/rules/collision.ts +4 -2
- package/rules/terrain.ts +2 -1
- package/rules/tiles.ts +8 -4
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-graphics.mjs +8 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +61 -17
- package/worker/app-streams.mjs +57 -0
- package/worker/app-tools.mjs +44 -0
- package/worker/booking-holds.mjs +87 -0
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +31 -0
- package/worker/connector-token.mjs +11 -0
- package/worker/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +24 -6
- package/worker/join-area.mjs +35 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +17 -9
- package/worker/pages.mjs +10 -7
- package/worker/records-page.mjs +5 -4
- package/worker/records-prefs.mjs +24 -0
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/worker/mcp.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {tokenCaller,toolInScope} from './connector-token.mjs';
|
|
1
2
|
import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
|
|
2
3
|
import {eventsRequest} from './mcp-events.mjs';
|
|
3
4
|
function freshErrorMessage(error,env,caller) {
|
|
@@ -38,10 +39,17 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
38
39
|
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
39
40
|
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
40
41
|
const localIdentity=await localCaller(request,env);
|
|
42
|
+
const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
|
|
43
|
+
const bearerIdentity=bearer?await tokenCaller(request,env):null;
|
|
44
|
+
if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
|
|
41
45
|
const api=async(request,env,ctx)=>{
|
|
42
|
-
const identity=()=>localIdentity ?? currentCaller(env,ctx.props);
|
|
46
|
+
const identity=async()=>{if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
|
|
43
47
|
let caller;
|
|
44
48
|
try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
49
|
+
if(caller.tools){
|
|
50
|
+
const body=request.method==='POST'?await request.clone().json().catch(()=>null):null;
|
|
51
|
+
if(request.method!=='POST'||!['initialize','notifications/initialized','ping','tools/list','tools/call'].includes(body?.method))return json({error:'connector is scoped to tools'},403);
|
|
52
|
+
}
|
|
45
53
|
const cat=await catalogueOf();
|
|
46
54
|
const tools=[...builtins(env,cat,origin),...definitions];
|
|
47
55
|
await pushToolList(env,cat,tools,origin);
|
|
@@ -110,12 +118,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
110
118
|
}
|
|
111
119
|
}
|
|
112
120
|
for(const tool of tools){
|
|
113
|
-
if (!await allowed(tool.audience,caller,env)) continue;
|
|
121
|
+
if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
|
|
114
122
|
if (tool.kind==='prompt') {
|
|
115
|
-
if (!await allowed(tool.audience,caller,env)) continue;
|
|
123
|
+
if (!toolInScope(caller,tool.name) || !await allowed(tool.audience,caller,env)) continue;
|
|
116
124
|
s.registerPrompt(tool.name,{description:tool.description,argsSchema:fromJsonSchema(tool.inputSchema,validator)},async(args)=>read('prompt:'+tool.name,async fresh=>{
|
|
117
|
-
if(!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
118
|
-
const text=await tool.handler(args,services(env,cat,origin,fresh,tool.namespace??tool.name,tool));
|
|
125
|
+
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env))throw new Error('Permission denied');
|
|
126
|
+
const text=await tool.handler(args,services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool));
|
|
119
127
|
return {messages:[{role:'user',content:{type:'text',text:JSON.stringify({kind:'studio-prompt-data',prompt:tool.name,data:text})}}]};
|
|
120
128
|
}));continue;
|
|
121
129
|
}
|
|
@@ -125,12 +133,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
125
133
|
try{
|
|
126
134
|
// Recheck inside dispatch, even when a connection listed tools earlier.
|
|
127
135
|
const fresh=await identity();
|
|
128
|
-
if(!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
136
|
+
if(!toolInScope(fresh,tool.name)||!await allowed(tool.audience,fresh,env)){outcome='denied';throw new Error('Permission denied');}
|
|
129
137
|
try{await rate(fresh);}catch(error){outcome='limited';throw error;}
|
|
130
|
-
const context=services(env,cat,origin,fresh,tool.namespace??tool.name,tool);
|
|
138
|
+
const context=services(env,cat,origin,fresh,(tool.namespace??tool.name)+(fresh.testScope?':check:'+fresh.testScope:''),tool);
|
|
131
139
|
if(tool.price) {const result=await selling.paidTool(env,cat,origin,tool,args,context,extra);outcome=result.isError?'error':'ok';return result;}
|
|
132
140
|
if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
|
|
133
|
-
const value=await
|
|
141
|
+
const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
|
|
134
142
|
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
135
143
|
outcome='ok';return {content:[{type:'text',text}]};
|
|
136
144
|
}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))}]};}
|
|
@@ -163,7 +171,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
163
171
|
};
|
|
164
172
|
return createMcpHandler(server,{route:path === '/mcp/auth' ? '/mcp/auth' : '/mcp'})(request,env,ctx);
|
|
165
173
|
};
|
|
166
|
-
if(localIdentity)return api(request,scoped,ctx);
|
|
174
|
+
if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
|
|
167
175
|
const provider=new OAuthProvider({
|
|
168
176
|
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
169
177
|
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
package/worker/pages.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {JOIN_AREA_SHELL_JS} from './join-area.mjs';
|
|
1
2
|
import { appWords, openPath } from './app-format.mjs';
|
|
2
3
|
import { APP_SHELL_JS } from './app-shell.mjs';
|
|
3
4
|
/**
|
|
@@ -121,7 +122,7 @@ export function sharePlaces(value) {
|
|
|
121
122
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
122
123
|
* covers the middle of the screen or a thumb.
|
|
123
124
|
*/
|
|
124
|
-
export function playPage(cat, g, { embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
125
|
+
export function playPage(cat, g, { test = false, embed = false, origin = '', preview = false, screen = false, joinUrl = null, qr = null, local = false, room = null, ticket = null, owner = false, launch = 'public', server = null, acct = false, member = false, shop = null } = {}) {
|
|
125
126
|
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
126
127
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
127
128
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
@@ -142,11 +143,13 @@ iframe.game { position: fixed; inset: 0; width: 100%; height: 100%; border: 0; d
|
|
|
142
143
|
.card { position: fixed; right: 12px; bottom: 12px; z-index: 4; max-width: 300px; padding: 10px 12px; border-radius: 12px; background: rgba(8,12,22,.82); border: 1px solid rgba(255,255,255,.14); color: #e8ecf5; font: 13px/1.35 ui-sans-serif, system-ui, sans-serif; }
|
|
143
144
|
.card h2 { margin: 0 0 4px; font-size: 13px; text-transform: uppercase; letter-spacing: .04em; color: var(--hot); }
|
|
144
145
|
.card ol { margin: 0; padding-left: 18px; }
|
|
145
|
-
|
|
146
|
+
:root{--homie-join-inset-top:0px;--homie-join-inset-right:0px;--homie-join-inset-bottom:0px;--homie-join-inset-left:0px;--homie-join-x:0px;--homie-join-y:0px;--homie-join-width:0px;--homie-join-height:0px}
|
|
147
|
+
.join { box-sizing:border-box;width:480px;max-width:calc(100vw - 32px);max-height:280px;overflow:hidden; position: fixed; right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); z-index: 6; display: flex; gap: 14px; align-items: center; padding: 12px 14px; border-radius: 16px; background: rgba(8,12,22,.78); border: 1px solid rgba(255,255,255,.14); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 22px)/1.3 ui-sans-serif, system-ui, sans-serif; pointer-events: none; }
|
|
148
|
+
.join[hidden]{display:none}
|
|
146
149
|
.join.join-top-left { top: max(16px, env(safe-area-inset-top)); left: max(16px, env(safe-area-inset-left)); right: auto; bottom: auto; }
|
|
147
150
|
.join.join-top-right { top: max(16px, env(safe-area-inset-top)); bottom: auto; }
|
|
148
151
|
.join.join-bottom-left { left: max(16px, env(safe-area-inset-left)); right: auto; }
|
|
149
|
-
.join .qr { width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
152
|
+
.join > div:last-child{min-width:0;overflow-wrap:anywhere}.join .qr { flex-shrink:0; width: clamp(96px, 15vmin, 220px); height: clamp(96px, 15vmin, 220px); border-radius: 8px; overflow: hidden; }
|
|
150
153
|
.join .qr svg { width: 100%; height: 100%; display: block; }
|
|
151
154
|
.join b { display: block; color: var(--hot); font-size: 1.15em; margin-bottom: 4px; }
|
|
152
155
|
.join span { opacity: .85; word-break: break-all; }
|
|
@@ -211,7 +214,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
211
214
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
212
215
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
213
216
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
214
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
217
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', test, appWords: appWords(g) } : {}), embed, game: g.id, name: g.name, screen: Boolean(screen), share: places, params: playParams(g), paramValue: PARAM_VALUE, prefs: PREFS_LIMITS, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}), sh: shellKey(g), ...(owner ? { owner: true, launch } : {}), ...(srv ? { server: srv } : {}), vote, skills: SKILLS.map((k) => ({ level: k.level, name: k.name, card: k.card })), words: POLICY_WORDS, kidsLine: KIDS_LINE, ...(shop ? { shop } : {}) };
|
|
215
218
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
216
219
|
const joinCorner = corner('join', 'bottom-right');
|
|
217
220
|
const first = places.desk;
|
|
@@ -248,7 +251,7 @@ ${arrive.html}
|
|
|
248
251
|
<div class="vote" data-vote role="dialog" aria-label="How strong should the AI be?" data-keep-focus hidden></div>
|
|
249
252
|
<div class="card" data-results hidden></div>
|
|
250
253
|
<div class="card" data-screen hidden></div>
|
|
251
|
-
${joinCard}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
254
|
+
${joinCard}${joinCard ? `<script>${JOIN_AREA_SHELL_JS}</script>` : ''}${share}${shopControl ? '<div class="shop-control"><button class="pill" type="button" data-shop-open data-shop-control>Shop</button></div>' : ''}
|
|
252
255
|
<script>window.__HOMIE_PLAY=${JSON.stringify(boot).replace(/</g, '\\u003c')};</script>
|
|
253
256
|
<script>window.__HOMIE_CHAT=${JSON.stringify(chatBoot(g, { surface: screen ? 'tv' : 'play', owner, acct, member })).replace(/</g, '\\u003c')};</script>
|
|
254
257
|
<script>${CHAT_JS}</script>
|
|
@@ -1170,10 +1173,10 @@ const OWNER_JS = String.raw`(function () {
|
|
|
1170
1173
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
1171
1174
|
* is the play door's). Every name a player typed is set with textContent.
|
|
1172
1175
|
*/
|
|
1173
|
-
export function watchPage(cat, g, { origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1176
|
+
export function watchPage(cat, g, { test = false, origin = '', room = null, ticket = null, policy = 'follow', owner = false, acct = false, member = false } = {}) {
|
|
1174
1177
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
1175
1178
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
1176
|
-
const boot = { ...(g.kind === 'app' ? { kind: 'app', screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1179
|
+
const boot = { ...(g.kind === 'app' ? { kind: 'app', test, screen: true, role: g.surfaces?.wall, params: playParams(g) } : {}), game: g.id, name: g.name, policy, palette: NET_PALETTE, ...(room ? { room } : {}), ...(ticket ? { t: ticket } : {}) };
|
|
1177
1180
|
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
|
|
1178
1181
|
return layoutless(`Watch ${g.name}`, `
|
|
1179
1182
|
<header class="wtop" data-top>
|
package/worker/records-page.mjs
CHANGED
|
@@ -2,12 +2,13 @@ import {playerProperties} from './embed.mjs';
|
|
|
2
2
|
import {appRole} from './app-format.mjs';
|
|
3
3
|
import {esc} from './site.mjs';
|
|
4
4
|
import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
5
|
+
import {RECORDS_PREFS_JS} from './records-prefs.mjs';
|
|
5
6
|
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
6
7
|
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
7
|
-
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');
|
|
8
|
+
export function recordsPage(meta,url,cat={},shop=null,test=false) {
|
|
9
|
+
const params=new URLSearchParams(url.search);params.delete('access');params.set('pf','1');params.set('role',appRole(meta,url));if(!params.has('surface'))params.set('surface',url.pathname.endsWith('/tv')?'wall':'phone');
|
|
9
10
|
const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
|
|
10
|
-
const boot={kind:'app',game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
11
|
+
const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
11
12
|
const source=`/${meta.id}/__game/?${params}`;
|
|
12
|
-
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
13
|
+
return new Response(`<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${esc(meta.name)}</title>${tags}<style>${SHOP_SHELL_CSS}html,body{margin:0;height:100%;background:#10151c;color:white;font:18px system-ui}iframe{border:0;width:100%;height:100%;display:block}[data-arrive]{position:fixed;inset:0;display:grid;place-items:center;background:#10151c}[data-arrive][hidden]{display:none;opacity:0;visibility:hidden}</style></head><body><div data-arrive>Opening ${esc(meta.name)}…</div><iframe class="game" title="${esc(meta.name)}" sandbox="allow-scripts allow-forms allow-modals allow-popups allow-downloads" allow="autoplay *; fullscreen *; web-share *"></iframe><script>window.__HOMIE_APP=${JSON.stringify(boot).replace(/</g,'\\u003c')};window.__HOMIE_PLAY=window.__HOMIE_APP;window.__shell={room:new URLSearchParams(location.search).get('room')||'main',link:{state:'connecting'},arrival:{phase:'loading'}};</script><script>${APP_SHELL_JS}</script><script>${RECORDS_PREFS_JS}</script><script>${SHOP_SHELL_JS}</script><script>const frame=document.querySelector('iframe');frame.addEventListener('load',()=>{if(!frame.getAttribute('src'))return;requestAnimationFrame(()=>requestAnimationFrame(()=>{document.querySelector('[data-arrive]').hidden=true;window.__shell.link.state='online';window.__shell.arrival.phase='done';}));});frame.src=${JSON.stringify(source).replace(/</g,'\\u003c')};</script></body></html>`,{headers:{'content-type':'text/html;charset=utf-8','cache-control':'no-store, no-transform','x-frame-options':'SAMEORIGIN'}});
|
|
13
14
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/** The records-only shell keeps net.prefs outside its opaque app frame. */
|
|
2
|
+
export const RECORDS_PREFS_JS=String.raw`(()=>{
|
|
3
|
+
const boot=window.__HOMIE_APP,frame=document.querySelector('iframe.game');
|
|
4
|
+
const storageKey='homie-prefs.'+boot.game;let memory=null;
|
|
5
|
+
function read(){if(memory)return memory;try{const v=JSON.parse(localStorage.getItem(storageKey)||'{}');return v&&typeof v==='object'&&!Array.isArray(v)?v:{};}catch{return memory={};}}
|
|
6
|
+
addEventListener('message',e=>{
|
|
7
|
+
const m=e.data;if(e.source!==frame.contentWindow||m?.t!=='homie-net'||m.what!=='prefs')return;
|
|
8
|
+
const result={t:'homie-prefs',n:m.n,ok:false};
|
|
9
|
+
try{
|
|
10
|
+
const all=read(),key=m.k;
|
|
11
|
+
if(m.op==='all'){result.ok=true;result.all=all;}
|
|
12
|
+
else if(typeof key!=='string'||!key.length||key.length>64||key==='__proto__')result.why='key';
|
|
13
|
+
else if(m.op==='set'||m.op==='del'){
|
|
14
|
+
const next=Object.fromEntries(Object.entries(all).filter(([k])=>k!==key));
|
|
15
|
+
if(m.op==='set'&&m.v!=null)next[key]=m.v;
|
|
16
|
+
const text=JSON.stringify(next);
|
|
17
|
+
if(text.length>16384||Object.keys(next).length>32)result.why='too-large';
|
|
18
|
+
else if(typeof m.v==='number'&&!Number.isFinite(m.v))result.why='value';
|
|
19
|
+
else{result.ok=true;result.kept='kept';try{if(memory)throw Error('memory');localStorage.setItem(storageKey,text);}catch{memory=next;result.kept='memory';}}
|
|
20
|
+
}else result.why='op';
|
|
21
|
+
}catch{result.why='error';}
|
|
22
|
+
e.source.postMessage(result,'*');
|
|
23
|
+
});
|
|
24
|
+
})();`;
|
package/worker/shop-links.mjs
CHANGED
|
@@ -45,3 +45,15 @@ export async function rememberedMoney(env, payment, mode) {
|
|
|
45
45
|
const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
|
|
46
46
|
return rows.map((r) => JSON.parse(r.value));
|
|
47
47
|
}
|
|
48
|
+
|
|
49
|
+
/** Asset configuration overrides legacy secrets, including an explicitly closed setup.
|
|
50
|
+
* Return a request-local env: never mutate shared bindings or cache across deployments. */
|
|
51
|
+
export async function loadShopLinks(env, origin='https://studio.invalid') {
|
|
52
|
+
if(!env.STRIPE_SHOP_LINKS_ASSET)return env;
|
|
53
|
+
let value;
|
|
54
|
+
try {
|
|
55
|
+
const response=await env.ASSETS.fetch(new Request(new URL(env.STRIPE_SHOP_LINKS_ASSET,origin)));
|
|
56
|
+
if(response.ok)value=await response.json();
|
|
57
|
+
} catch {}
|
|
58
|
+
return {...env,STRIPE_SHOP_LINKS:JSON.stringify(value?.config??null),STRIPE_SHOP_SETUP:value?.next??'Ask your AI to finish stripe_shop_plan with verify:true.'};
|
|
59
|
+
}
|
package/worker/shop.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {claimHoldStatement,ownedHold} from './booking-holds.mjs';
|
|
1
2
|
import { stripeEventOwner } from './stripe-ownership.mjs';
|
|
2
3
|
import { localSession } from './local-test.mjs';
|
|
3
4
|
import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-subscriptions.mjs';
|
|
@@ -154,7 +155,7 @@ export async function readiness(env, shop) {
|
|
|
154
155
|
let migration = null;
|
|
155
156
|
if (!env?.DB) missing.push('database');
|
|
156
157
|
else if ((migration = await migrationNeeded(env))) missing.push('migration');
|
|
157
|
-
return { ready: missing.length === 0, missing, next: missing.length ? MISSING_WORDS[missing[0]] : null, migration, mode: paidItems ? shopMode(env) : null };
|
|
158
|
+
return { ready: missing.length === 0, missing, next: missing.length ? (missing[0]==='payment-links' && env.STRIPE_SHOP_SETUP || MISSING_WORDS[missing[0]]) : null, migration, mode: paidItems ? shopMode(env) : null };
|
|
158
159
|
}
|
|
159
160
|
|
|
160
161
|
export const MISSING_WORDS = Object.freeze({
|
|
@@ -516,6 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
516
517
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
517
518
|
const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
|
|
518
519
|
const config=linkConfig(env);
|
|
520
|
+
if(booking?.hold&&keyless)return {error:fail(409,'booking','A held slot needs session checkout; keyless payment links cannot claim it.')};
|
|
519
521
|
if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
|
|
520
522
|
const menu=keyless && !booking && lines.length>0 ? config?.menus?.find(m=>(m.basket || lines.length>1)&&lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
|
|
521
523
|
const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
|
|
@@ -546,7 +548,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
546
548
|
lines = lines.map((line, i) => ({ ...line, id: `${order.id}_${i}` }));
|
|
547
549
|
const reserve = async () => {
|
|
548
550
|
const results = await env.DB.batch([reservation(),
|
|
549
|
-
...(booking ? [bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
|
|
551
|
+
...(booking ? [booking.hold?claimHoldStatement(env,{...booking,order:order.id}):bookingStatement(env, { ...booking, pending: keyless, id: order.id, app: booking.app ?? game, order: order.id })] : []),
|
|
550
552
|
...(reference === null ? [] : [env.DB.prepare("INSERT INTO meta(key,value) SELECT ?1,?2 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id=?3)").bind(`shop-reference:${order.id}`, reference, order.id)]),
|
|
551
553
|
...(menu ? [env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`shop-menu:${order.id}`,JSON.stringify(lines))] : []),
|
|
552
554
|
...lines.map((line, i) => env.DB.prepare("INSERT INTO shop_order_lines (id, order_id, position, item, quantity, unit_amount, amount, snapshot) SELECT ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8 WHERE EXISTS (SELECT 1 FROM shop_orders WHERE id = ?2)")
|
|
@@ -558,7 +560,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
558
560
|
await reconcileOrders(env, shop, player.id, { expireOpen: true, before: Math.min(requestAt, now - 60000) });
|
|
559
561
|
let reserved;
|
|
560
562
|
try { reserved = await reserve(); } catch (error) {
|
|
561
|
-
if (/booking capacity unavailable/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
|
|
563
|
+
if (/booking capacity unavailable|booking hold changed or expired/.test(error.message)) return { error: fail(409, 'capacity', 'That slot is full. Choose another slot.') };
|
|
562
564
|
throw error;
|
|
563
565
|
}
|
|
564
566
|
const reconciled = !reserved.meta?.changes;
|
|
@@ -679,9 +681,17 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
679
681
|
if (!spec || !/^[A-Za-z0-9_-]{1,64}$/.test(requestedBooking?.slot ?? '') || lines.length !== 1 || item.id !== spec.item || item.interval) return fail(400, 'booking', 'Choose a declared booking slot and its service item.');
|
|
680
682
|
const local = await localSession(request, env);
|
|
681
683
|
booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
|
|
684
|
+
if(requestedBooking.hold!==undefined){
|
|
685
|
+
try {
|
|
686
|
+
const caller=local?.app===game?{id:`local-test-${local.app}-${local.role}`,testScope:local.scope}:{id:player.guest?null:player.id};
|
|
687
|
+
const hold=await ownedHold(env,caller,{id:requestedBooking.hold,token:requestedBooking.token});
|
|
688
|
+
if(hold.app!==booking.app||hold.collection!==collection||hold.slot!==booking.slot||hold.quantity!==booking.quantity||hold.order_id!=='hold:'+hold.id||!['held','confirmed'].includes(hold.state)||(hold.state==='held'&&hold.hold_until<=Date.now()))return fail(409,'booking','Hold changed or expired; reload.');
|
|
689
|
+
booking.hold=hold.id;
|
|
690
|
+
}catch{return fail(403,'booking','Booking owner required');}
|
|
691
|
+
}
|
|
682
692
|
}
|
|
683
693
|
const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
|
|
684
|
-
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: r.order.id } : {}) });
|
|
694
|
+
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: booking.hold??r.order.id } : {}) });
|
|
685
695
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
686
696
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
|
687
697
|
return response;
|
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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.52.0';
|