@homie-rocks/studio 0.52.0 → 0.53.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 +27 -0
- package/apps/apps.ts +17 -0
- package/bin/homie-studio.mjs +8 -2
- package/docs/shop-migration.md +9 -0
- package/docs/toolkit-batch.md +49 -0
- package/functions/FUNCTIONS.md +15 -1
- package/guides/app/PLACES.md +23 -0
- package/lib/app-needs.mjs +19 -10
- package/lib/app-ready.mjs +1 -1
- package/lib/build.mjs +3 -2
- package/lib/check.mjs +37 -13
- package/lib/dev.mjs +1 -0
- package/lib/fork.mjs +47 -0
- package/lib/jobs.mjs +1 -0
- package/lib/mcp-tools.mjs +1 -1
- package/lib/needs-headless.mjs +4 -3
- package/lib/scaffold.mjs +8 -1
- package/lib/shop-links.mjs +3 -3
- package/lib/shop-mcp.mjs +6 -0
- package/lib/stripe-connect.mjs +37 -2
- package/lib/template-history.json +42 -0
- package/lib/tools-cli.mjs +25 -3
- package/lib/worker-config.mjs +1 -0
- package/package.json +1 -1
- package/rules/rules.ts +1 -1
- package/shop/shop.ts +3 -3
- package/tools/tools.ts +3 -2
- package/worker/app-format.mjs +6 -3
- package/worker/app-records.mjs +16 -4
- package/worker/app-shell.mjs +1 -1
- package/worker/app-tools.mjs +1 -1
- package/worker/app-web.mjs +23 -0
- package/worker/booking-calendar.mjs +26 -0
- package/worker/booking-holds.mjs +18 -5
- package/worker/booking-options.mjs +23 -0
- package/worker/booking-resources.mjs +55 -0
- package/worker/build-id.mjs +1 -1
- package/worker/customer-mcp.mjs +1 -1
- package/worker/index.mjs +36 -6
- package/worker/local-test.mjs +10 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +20 -2
- package/worker/office.mjs +27 -0
- package/worker/records-page.mjs +2 -2
- package/worker/shop.mjs +6 -1
- package/worker/version.mjs +1 -1
package/worker/index.mjs
CHANGED
|
@@ -123,7 +123,7 @@ import { cookieValues, count, countVisit, counter, isQa, onlyOf, ownerAllowed, p
|
|
|
123
123
|
import { ownerRoutes } from './stats-page.mjs';
|
|
124
124
|
import { playerRoutes, players as playerAccounts } from './players.mjs';
|
|
125
125
|
import {
|
|
126
|
-
accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
126
|
+
appAssetTicket, accessOf, accountSub, gatePage, holders, isOwner, joinHolders, launchOf, regateArgs, officeRoutes, publicCatalogue, redeemInvite, sameOrigin, seatsFor, settingsOf, ticketAllows,
|
|
127
127
|
ticketFor, ticketSub, usePlayers, verifyControl,
|
|
128
128
|
} from './office.mjs';
|
|
129
129
|
|
|
@@ -154,7 +154,7 @@ export { mergeSaves } from './saves.mjs';
|
|
|
154
154
|
const GAME_ID = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
155
155
|
const POST_SLUG = /^[a-z0-9][a-z0-9-]{0,79}$/;
|
|
156
156
|
/** A game's own files, framed by its play page: never x-frame-options DENY. */
|
|
157
|
-
const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/__game(?:\/|$)/;
|
|
157
|
+
const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
|
|
158
158
|
const MEDIA_FILE = /\.(mp4|webm|m4v|mov|mp3|m4a|aac|ogg|opus|wav|flac)$/i;
|
|
159
159
|
/** A browser's room key (random, kept in the play page's own storage) and a play ticket, as they may appear in an address. */
|
|
160
160
|
const BROWSER_KEY = /^[A-Za-z0-9_-]{16,43}$/;
|
|
@@ -539,7 +539,8 @@ async function gameDocument(request, env, url, game, meta, cat, { agent = null }
|
|
|
539
539
|
};
|
|
540
540
|
let html = await res.text();
|
|
541
541
|
const fallback = meta?.kind === 'app' ? `<script>addEventListener('DOMContentLoaded',()=>(${appGraphicsFallback.toString()})(${JSON.stringify(meta.words?.fallback ?? 'This device cannot display the 3D scene. Use the text controls to continue.').replace(/</g, '\\u003c')}));</script>` : '';
|
|
542
|
-
const
|
|
542
|
+
const assetBase = meta.kind === 'app' && !url.pathname.includes('/__assets/') && launchOf(meta, await settingsOf(env, {fresh:true}), env) !== 'public' ? `<base href="/${game}/__assets/${await appAssetTicket(env,game)}/">` : '';
|
|
543
|
+
const head = assetBase + `<script>${JOIN_AREA_FRAME_JS}</script>` + (meta.kind==='app'?`<script>${APP_STORAGE_JS}</script>`:'') + fallback + `${embedded ? `<script>${EMBED_GAME_JS}</script>` : ''}<script>window.HOMIE_NET=${JSON.stringify(cfg).replace(/</g, '\\u003c')}</script>`;
|
|
543
544
|
html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, `<head$1>${head}`) : head + html;
|
|
544
545
|
return new Response(html, {
|
|
545
546
|
headers: {
|
|
@@ -637,6 +638,8 @@ export default {
|
|
|
637
638
|
env=await loadShopLinks(env,new URL(request.url).origin);
|
|
638
639
|
await removeReferralLeftovers(env);
|
|
639
640
|
const url = new URL(request.url);
|
|
641
|
+
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/robots.txt')return new Response('User-agent: *\nDisallow: /\n',{headers:{'content-type':'text/plain','x-robots-tag':'noindex, nofollow'}});
|
|
642
|
+
if(env.HOMIE_CONCEPT==='1' && url.pathname==='/.well-known/homie-studio.json')return json({ok:false,error:'concept-unlisted'},404,{'x-robots-tag':'noindex, nofollow'});
|
|
640
643
|
await (await import('./functions.mjs')).syncFunctions(env);
|
|
641
644
|
const dispatch=()=>import('./functions.mjs').then(m=>m.runFunctions(env,()=>catalogue(env,url.origin),url.origin));
|
|
642
645
|
// Some routes reconcile shop state in waitUntil after returning their page.
|
|
@@ -665,6 +668,16 @@ export default {
|
|
|
665
668
|
if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;
|
|
666
669
|
}
|
|
667
670
|
}
|
|
671
|
+
if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
|
|
672
|
+
const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
|
|
673
|
+
let body=res.body;
|
|
674
|
+
if(/text\/html/i.test(headers.get('content-type')??'')) {
|
|
675
|
+
const html=await res.text(),banner='<aside role="status" style="position:fixed;z-index:2147483647;top:0;left:0;right:0;text-align:center;background:#ffda44;color:#171717;padding:6px;font:bold 14px system-ui;pointer-events:none">TEST / CONCEPT</aside>';
|
|
676
|
+
body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
|
|
677
|
+
headers.delete('content-length');
|
|
678
|
+
}
|
|
679
|
+
res=new Response(body,{status:res.status,headers});
|
|
680
|
+
}
|
|
668
681
|
if (appLobby || appRecords) return appCors(request, res);
|
|
669
682
|
return res;
|
|
670
683
|
},
|
|
@@ -842,7 +855,7 @@ async function route(request, env, ctx) {
|
|
|
842
855
|
if (own.ok) return own;
|
|
843
856
|
const cat = await getCat();
|
|
844
857
|
const method = request.method;
|
|
845
|
-
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' }), 'text/plain', { method });
|
|
858
|
+
if (path === '/robots.txt') return discoveryResponse(robotsTxt(cat, url.origin, { preview: env.HOMIE_PREVIEW === '1' || env.HOMIE_CONCEPT === '1' }), 'text/plain', { method });
|
|
846
859
|
const all = await getAll();
|
|
847
860
|
if (path === '/sitemap.xml') return discoveryResponse(sitemapXml(cat, url.origin, { all }), 'application/xml', { method });
|
|
848
861
|
const full = path === '/llms-full.txt';
|
|
@@ -927,6 +940,10 @@ async function route(request, env, ctx) {
|
|
|
927
940
|
return postPage(cat, post, { origin: url.origin });
|
|
928
941
|
}
|
|
929
942
|
|
|
943
|
+
if(parts[0]==='games' && parts.length>2) {
|
|
944
|
+
const all=await getAll(),app=(all.games??[]).find(g=>g.id===parts[1]&&g.kind==='app');
|
|
945
|
+
if(app && launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return json({ok:false,error:'not-found'},404);
|
|
946
|
+
}
|
|
930
947
|
const game = parts[0];
|
|
931
948
|
if (game && GAME_ID.test(game)) {
|
|
932
949
|
const all = await getAll();
|
|
@@ -949,7 +966,20 @@ async function route(request, env, ctx) {
|
|
|
949
966
|
const launch = launchOf(meta, settings, env);
|
|
950
967
|
const max = seatsFor(meta, settings);
|
|
951
968
|
if (meta.kind === 'app') {
|
|
952
|
-
|
|
969
|
+
const asset=/^__assets\/([^/]+)\/(.+)$/.exec(sub);
|
|
970
|
+
if(asset) {
|
|
971
|
+
if(!(await appAssetTicket(env,game,asset[1])))return json({ok:false,error:'not-found'},404);
|
|
972
|
+
const response=asset[2]==='index.html'?await gameDocument(request,env,url,game,meta,cat):await gameAsset(request,env,url,game,asset[2]);
|
|
973
|
+
response.headers.set('cache-control','private, no-store');
|
|
974
|
+
response.headers.set('referrer-policy','no-referrer');
|
|
975
|
+
return response;
|
|
976
|
+
}
|
|
977
|
+
const frameTicket = ['__game','__game/','__game/index.html','__net','__watch'].includes(sub) && await ticketAllows(env, await ticketSub(env,game,url.searchParams.get('t')), launch);
|
|
978
|
+
if (launch !== 'public' && !frameTicket && !(await accessOf(request, env, game, launch)).ok && (await (await import('./local-test.mjs')).localSession(request,env))?.app!==game) return json({ ok: false, error: 'not-found' }, 404);
|
|
979
|
+
if(launch==='public') {
|
|
980
|
+
const web=await (await import('./app-web.mjs')).appWeb(request,env,meta,url,sub,()=>gameDocument(request,env,url,game,meta,cat));
|
|
981
|
+
if(web)return web;
|
|
982
|
+
}
|
|
953
983
|
if(sub==='api/app/live') {
|
|
954
984
|
if(request.method!=='GET')return new Response('GET required',{status:405});
|
|
955
985
|
const {appHub}=await import('./app-streams.mjs');
|
|
@@ -965,7 +995,7 @@ async function route(request, env, ctx) {
|
|
|
965
995
|
if (a.status === 401) return Response.redirect(`${url.origin}/account/?next=${encodeURIComponent(url.pathname + url.search)}`, 302);
|
|
966
996
|
return json({ ok: false, error: a.error }, a.status);
|
|
967
997
|
}
|
|
968
|
-
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)));
|
|
998
|
+
if(!meta.room)return recordsPage(meta,url,cat,await availableShellShop(env,cat,game),Boolean(await (await import('./local-test.mjs')).localSession(request,env)),launch!=='public'?await appAssetTicket(env,game):null);
|
|
969
999
|
}
|
|
970
1000
|
}
|
|
971
1001
|
const lobby = () => env.LOBBY.get(env.LOBBY.idFromName(game));
|
package/worker/local-test.mjs
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/** Only dev supplies this secret. Neither a loopback URL nor a role parameter grants authority. */
|
|
2
2
|
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
3
|
export async function localCaller(request, env) {
|
|
4
|
+
const remote=await remoteSession(request,env);
|
|
5
|
+
if(remote)return {id:`check-${remote.app}-${remote.role}`,owner:false,client:null,localTest:true,remoteTest:true,roles:{[remote.app]:[remote.role]},testApp:remote.app,testScope:remote.scope};
|
|
4
6
|
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
7
|
const session=await localSession(request,env);
|
|
6
8
|
// A supplied but invalid signed session must never fall back to owner.
|
|
@@ -9,6 +11,7 @@ export async function localCaller(request, env) {
|
|
|
9
11
|
return {id:'local-test-owner',owner:true,client:null};
|
|
10
12
|
}
|
|
11
13
|
export async function localSession(request, env) {
|
|
14
|
+
const remote=await remoteSession(request,env);if(remote)return remote;
|
|
12
15
|
if (!local(request, env)) return null;
|
|
13
16
|
const token = /(?:^|;\s*)homie_test=([^;]+)/.exec(request.headers.get('cookie') ?? '')?.[1];
|
|
14
17
|
if (!token) return null;
|
|
@@ -21,3 +24,10 @@ export async function localSession(request, env) {
|
|
|
21
24
|
return /^[a-z][a-z0-9-]*$/.test(session.app) && /^[a-z][a-z0-9-]*$/.test(session.role) && (!session.scope || /^[a-zA-Z0-9-]+$/.test(session.scope)) ? session : null;
|
|
22
25
|
} catch { return null; }
|
|
23
26
|
}
|
|
27
|
+
|
|
28
|
+
async function remoteSession(request,env) {
|
|
29
|
+
const token=/(?:^|;\s*)homie_check=([^;]+)/.exec(request.headers.get('cookie')??'')?.[1];
|
|
30
|
+
if(!token)return null;
|
|
31
|
+
const session=await (await import('./office.mjs')).checkSessionTicket(env,null,token);
|
|
32
|
+
return session?.origin===new URL(request.url).origin?{...session,remoteTest:true}:null;
|
|
33
|
+
}
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -41,9 +41,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
|
41
41
|
const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
|
|
42
42
|
WHERE type=?1 AND seq>?2 AND seq<=?3 AND scope IS ?7
|
|
43
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'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1,feed.event==='record.changed'?null:caller.testScope??null).all()).results;
|
|
44
|
+
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1,feed.event==='record.changed'?null:caller.testScope??null).all()).results;
|
|
45
45
|
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
|
-
const events=page.map(row=>{const data=JSON.parse(row.data);if(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
46
|
+
const events=page.map(row=>{const data=JSON.parse(row.data);if((env.HOMIE_LOCAL_TEST_SECRET||caller.remoteTest)&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
47
47
|
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
48
|
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
|
|
49
49
|
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
|
|
1
2
|
import {bookingHolds} from './booking-holds.mjs';
|
|
2
3
|
import {toolDatabase} from './tool-database.mjs';
|
|
3
4
|
import { selling } from './extensions.mjs';
|
|
@@ -16,7 +17,7 @@ export async function allowed(audience, caller, env) {
|
|
|
16
17
|
if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
|
|
17
18
|
if (audience === 'owner') return caller.owner === true;
|
|
18
19
|
if (audience && typeof audience === 'object' && audience.app && audience.role) {
|
|
19
|
-
if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
|
|
20
|
+
if((env.HOMIE_LOCAL_TEST_SECRET || caller.remoteTest) && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
|
|
20
21
|
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());
|
|
21
22
|
}
|
|
22
23
|
return false;
|
|
@@ -38,9 +39,11 @@ export async function currentCaller(env, props) {
|
|
|
38
39
|
}
|
|
39
40
|
export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
40
41
|
const records = async ({app,role,collection,id,operation='read',data,version}) => {
|
|
42
|
+
if(operation!=='read'&&!id)throw Object.assign(new Error('Put id at the top level of the arguments, alongside app and collection (1–64 letters, digits, underscore or hyphen).'),{name:'StudioOperationError'});
|
|
43
|
+
if(caller.remoteTest&&app!==caller.testApp)throw new Error('Test session belongs to another app');
|
|
41
44
|
const meta = cat.games.find(g=>g.id===app && g.kind==='app');
|
|
42
45
|
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'});
|
|
43
|
-
if (!caller.owner && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
46
|
+
if (!caller.owner && !caller.remoteTest && launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public') throw new Error('app is private');
|
|
44
47
|
if(!role) {
|
|
45
48
|
const candidates=[];
|
|
46
49
|
for(const [name,declared] of Object.entries(meta.roles??{})) {
|
|
@@ -90,6 +93,13 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
90
93
|
export function builtins(env,cat,origin) {
|
|
91
94
|
const tool=(name,description,audience,inputSchema,handler)=>({name,description,audience,inputSchema,handler});
|
|
92
95
|
const tools=[
|
|
96
|
+
tool('studio_check_session','Owner: create a short-lived app-role test session with isolated records. Never grants global owner access.','owner',object({app:str,role:str,scope:{type:'string',pattern:'^check-[a-zA-Z0-9-]{1,64}$'}},['app','role','scope']),async(args,ctx)=>{
|
|
97
|
+
if(!ctx.caller.owner)throw new Error('Owner required');
|
|
98
|
+
const meta=cat.games.find(g=>g.id===args.app&&g.kind==='app');
|
|
99
|
+
if(!meta?.roles?.[args.role])throw new Error('Name a declared app role');
|
|
100
|
+
const token=await (await import('./office.mjs')).checkSessionTicket(env,{...args,origin});
|
|
101
|
+
return {token,expiresIn:900};
|
|
102
|
+
}),
|
|
93
103
|
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)=>{
|
|
94
104
|
if(!ctx.caller.owner)throw new Error('Owner only');
|
|
95
105
|
if(!Number.isSafeInteger(through)||through<0)throw new Error('Invalid event cursor');
|
|
@@ -166,6 +176,14 @@ export function builtins(env,cat,origin) {
|
|
|
166
176
|
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
167
177
|
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
168
178
|
}),
|
|
179
|
+
tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
|
|
180
|
+
const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
|
|
181
|
+
if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
|
|
182
|
+
const minutes=Number.isSafeInteger(spec.holdMinutes)&&spec.holdMinutes>0?Math.min(spec.holdMinutes,1440):15;
|
|
183
|
+
if(!Number.isSafeInteger(args.expiresAt)||args.expiresAt<=Date.now()||args.expiresAt>Date.now()+minutes*60000)throw Object.assign(new Error(`Hold expiry must be within ${minutes} minutes`),{name:'StudioOperationError'});
|
|
184
|
+
await ctx.records({app:args.app,collection:spec.slots,id:args.slot});
|
|
185
|
+
return ctx.bookings.hold(args);
|
|
186
|
+
}),
|
|
169
187
|
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)),
|
|
170
188
|
];
|
|
171
189
|
for(const [name,path,description] of [
|
package/worker/office.mjs
CHANGED
|
@@ -1377,3 +1377,30 @@ export async function officeRoutes(request, env, url, { catalogueOf }) {
|
|
|
1377
1377
|
}
|
|
1378
1378
|
return null;
|
|
1379
1379
|
}
|
|
1380
|
+
|
|
1381
|
+
/** Asset-only capability: cannot authenticate records, tools or room sockets. */
|
|
1382
|
+
export async function appAssetTicket(env, app, ticket) {
|
|
1383
|
+
const key=await officeKey(env);if(!key)return null;
|
|
1384
|
+
if(ticket===undefined) {
|
|
1385
|
+
const exp=(Date.now()+5*60_000).toString(36);
|
|
1386
|
+
return `${exp}.${(await mac(key,`app-assets|${app}|${exp}`)).slice(0,32)}`;
|
|
1387
|
+
}
|
|
1388
|
+
const m=/^([a-z0-9]+)\.([A-Za-z0-9_-]{32})$/.exec(ticket);
|
|
1389
|
+
return Boolean(m && parseInt(m[1],36)>Date.now() && same((await mac(key,`app-assets|${app}|${m[1]}`)).slice(0,32),m[2]));
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/** Owner-issued test role, scoped to one app/record namespace; never an owner credential. */
|
|
1393
|
+
export async function checkSessionTicket(env, data, token) {
|
|
1394
|
+
const key=await officeKey(env);if(!key)return null;
|
|
1395
|
+
if(token===undefined) {
|
|
1396
|
+
const payload=b64url(new TextEncoder().encode(JSON.stringify({...data,expires:Date.now()+15*60_000})));
|
|
1397
|
+
return payload+'.'+await mac(key,'check-session|'+payload);
|
|
1398
|
+
}
|
|
1399
|
+
try {
|
|
1400
|
+
const [payload,signature,...extra]=token.split('.');
|
|
1401
|
+
if(extra.length||!signature||!same(await mac(key,'check-session|'+payload),signature))return null;
|
|
1402
|
+
const session=JSON.parse(atob(payload.replaceAll('-','+').replaceAll('_','/')));
|
|
1403
|
+
if(session.expires<=Date.now()||!Number.isSafeInteger(session.expires)||!/^check-[a-zA-Z0-9-]{1,64}$/.test(session.scope)||!GAME_ID.test(session.app)||!/^[a-z][a-z0-9-]{0,39}$/.test(session.role))return null;
|
|
1404
|
+
return session;
|
|
1405
|
+
}catch{return null;}
|
|
1406
|
+
}
|
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,test=false) {
|
|
8
|
+
export function recordsPage(meta,url,cat={},shop=null,test=false,assetTicket=null) {
|
|
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
11
|
const boot={kind:'app',test,game:meta.id,screen:url.pathname.endsWith('/tv'),shop};
|
|
12
|
-
const source
|
|
12
|
+
const source=assetTicket?`/${meta.id}/__assets/${assetTicket}/index.html?${params}`:`/${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.mjs
CHANGED
|
@@ -577,7 +577,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
577
577
|
}
|
|
578
578
|
if (keyless) {
|
|
579
579
|
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
580
|
-
if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
|
|
580
|
+
if(menu?.basket || item.kind==='deposit')checkout.searchParams.set('prefilled_amount',String(amount));
|
|
581
581
|
return { order, url: checkout.href, reconciled };
|
|
582
582
|
}
|
|
583
583
|
let session = null;
|
|
@@ -650,6 +650,8 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
650
650
|
if (!item) return fail(404, 'item', 'no such item here');
|
|
651
651
|
const quantity = entry.quantity ?? 1;
|
|
652
652
|
if (!Number.isSafeInteger(quantity) || quantity < 1) return fail(400, 'arithmetic', 'Quantity is a positive safe integer.');
|
|
653
|
+
if(!hasStripeKey(env) && ['supporter','deposit'].includes(item.kind) && quantity!==1)return fail(400,'quantity','This payment link has quantity fixed at one.');
|
|
654
|
+
if(item.kind==='deposit' && (!reference || !Number.isSafeInteger(entry.amount??item.price) || (entry.amount??item.price)<=0))return fail(400,'deposit','A deposit requires the app reference and its saved positive amount.');
|
|
653
655
|
if (!shop.policy.repeatPurchases && (quantity > 1 || seen.has(item.id))) return fail(403, 'owned', 'The studio has disabled repeat purchases.');
|
|
654
656
|
seen.add(item.id);
|
|
655
657
|
const v = await itemsView(env, shop, { game, ...facts, player, open: true, item, purchasing: true });
|
|
@@ -679,6 +681,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
679
681
|
const collection = requestedBooking?.collection;
|
|
680
682
|
const spec = app?.records?.collections?.[collection]?.booking;
|
|
681
683
|
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.');
|
|
684
|
+
if(spec.resources&&requestedBooking.hold===undefined)return fail(409,'booking','Reserve the resource through the booking tool and pass its hold before checkout.');
|
|
682
685
|
const local = await localSession(request, env);
|
|
683
686
|
booking = { app: local?.app === game && local.scope ? `${game}:check:${local.scope}` : game, collection, slots: spec.slots, slot: requestedBooking.slot, quantity: lines[0].quantity };
|
|
684
687
|
if(requestedBooking.hold!==undefined){
|
|
@@ -976,7 +979,9 @@ async function bindLinkSession(env, session, mode) {
|
|
|
976
979
|
if (!link || (session.payment_link?.id ?? session.payment_link) !== link.id || session.metadata?.revision !== link.revision || session.currency !== source.currency) return false;
|
|
977
980
|
const amount = session.amount_subtotal;
|
|
978
981
|
if (!Number.isSafeInteger(amount) || amount <= 0) return false;
|
|
982
|
+
if(item.kind==='deposit' && amount!==source.amount)return false;
|
|
979
983
|
const quantity = item.price === 'choose' ? 1 : amount / item.price;
|
|
984
|
+
if(['supporter','deposit'].includes(item.kind) && quantity!==1)return false;
|
|
980
985
|
if (!Number.isSafeInteger(quantity) || quantity < 1 || quantity > 999999) return false;
|
|
981
986
|
if (item.price === 'choose' && (amount < item.min || item.max !== null && amount > item.max)) return false;
|
|
982
987
|
// Claim the first session atomically. A repeated URL creates a separate paid order, never loses a payment.
|
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.53.0';
|