@homie-rocks/studio 0.51.8 → 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 +32 -0
- package/apps/APPS.md +117 -0
- package/apps/apps.ts +51 -1
- package/docs/toolkit-place-patterns.md +6 -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 +3 -0
- package/guides/game/SKILL.md +3 -0
- package/guides/shop/SKILL.md +27 -12
- package/lib/app-needs.mjs +41 -6
- package/lib/build.mjs +5 -0
- package/lib/check.mjs +6 -6
- package/lib/cloudflare.mjs +2 -2
- 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 +42 -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/package.json +1 -1
- package/shop/SHOP.md +27 -12
- package/shop/shop.ts +1 -1
- package/tools/tools.ts +12 -0
- package/worker/app-records.mjs +5 -1
- package/worker/app-shell.mjs +27 -4
- 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/event-resources.mjs +1 -1
- package/worker/functions.mjs +5 -0
- package/worker/index.mjs +19 -4
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +3 -0
- package/worker/mcp.mjs +3 -3
- package/worker/pages.mjs +4 -4
- package/worker/records-page.mjs +2 -2
- package/worker/shop-links.mjs +12 -0
- package/worker/shop.mjs +14 -4
- package/worker/version.mjs +1 -1
package/worker/pages.mjs
CHANGED
|
@@ -122,7 +122,7 @@ export function sharePlaces(value) {
|
|
|
122
122
|
* the edge (game.json `screen.share`, per device: sharePlaces) opens Invite, Big screen and the room code; nothing
|
|
123
123
|
* covers the middle of the screen or a thumb.
|
|
124
124
|
*/
|
|
125
|
-
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 } = {}) {
|
|
126
126
|
const shopControl = Boolean(shop && !screen && g.screen?.shop !== false);
|
|
127
127
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
128
128
|
const corner = (name, fallback) => (['top-left', 'top-right', 'bottom-left', 'bottom-right'].includes(g.screen?.[name]) ? g.screen[name] : fallback);
|
|
@@ -214,7 +214,7 @@ ${SERVER_CSS}${CHAT_CSS}${g.saves && !screen ? SAVES_SHELL_CSS : ''}${shop ? SHO
|
|
|
214
214
|
// game.json "agents": { "vote": "game" } draws its own vote card; false turns the vote off (section 17).
|
|
215
215
|
const vote = g.agents?.vote === 'game' || g.agents?.vote === false ? g.agents.vote : 'shell';
|
|
216
216
|
// The address's switches the game's frame is handed (NETPLAY.md section 24): the defaults and the game's own names.
|
|
217
|
-
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 } : {}) };
|
|
218
218
|
// game.json "screen": { "join": "top-left" | "top-right" | "bottom-left" | "bottom-right" } keeps the card off the game's own HUD.
|
|
219
219
|
const joinCorner = corner('join', 'bottom-right');
|
|
220
220
|
const first = places.desk;
|
|
@@ -1173,10 +1173,10 @@ const OWNER_JS = String.raw`(function () {
|
|
|
1173
1173
|
* watchers see the whole room only. A private or invite-only game is watched only by whoever may play it (the door
|
|
1174
1174
|
* is the play door's). Every name a player typed is set with textContent.
|
|
1175
1175
|
*/
|
|
1176
|
-
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 } = {}) {
|
|
1177
1177
|
const accent = cat?.studio?.theme?.accent ?? '#ffcf5a';
|
|
1178
1178
|
const hot = /^#[0-9a-f]{3,8}$/i.test(accent) ? accent : '#ffcf5a';
|
|
1179
|
-
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 } : {}) };
|
|
1180
1180
|
const css = `:root{--hot:${hot}}${WATCH_CSS}${CHAT_CSS}${g.kind === 'app' ? '.dock{display:none}' : ''}`;
|
|
1181
1181
|
return layoutless(`Watch ${g.name}`, `
|
|
1182
1182
|
<header class="wtop" data-top>
|
package/worker/records-page.mjs
CHANGED
|
@@ -5,10 +5,10 @@ import {SHOP_SHELL_JS, SHOP_SHELL_CSS} from './shop-page.mjs';
|
|
|
5
5
|
import {RECORDS_PREFS_JS} from './records-prefs.mjs';
|
|
6
6
|
import {APP_SHELL_JS} from './app-shell.mjs';
|
|
7
7
|
/** A records-only app has no room simulation. The shell owns one frame and its authenticated records bridge. */
|
|
8
|
-
export function recordsPage(meta,url,cat={},shop=null) {
|
|
8
|
+
export function recordsPage(meta,url,cat={},shop=null,test=false) {
|
|
9
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');
|
|
10
10
|
const tags=Object.entries(playerProperties(cat,meta,{origin:url.origin})).map(([name,content])=>`<meta name="${esc(name)}" content="${esc(content)}">`).join('');
|
|
11
|
-
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};
|
|
12
12
|
const source=`/${meta.id}/__game/?${params}`;
|
|
13
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'}});
|
|
14
14
|
}
|
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';
|