@homie-rocks/studio 0.53.0 → 0.55.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.
Files changed (90) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/bin/homie-studio.mjs +1 -1
  3. package/guides/app/SKILL.md +2 -2
  4. package/guides/shop/SKILL.md +6 -0
  5. package/lib/app-needs.mjs +8 -1
  6. package/lib/background-queue.mjs +31 -0
  7. package/lib/build.mjs +5 -2
  8. package/lib/check.mjs +10 -2
  9. package/lib/cloudflare.mjs +16 -1
  10. package/lib/dev.mjs +8 -1
  11. package/lib/read-replication.mjs +26 -0
  12. package/lib/scaffold.mjs +46 -9
  13. package/lib/shop-links.mjs +37 -19
  14. package/lib/shop-mcp.mjs +7 -1
  15. package/lib/shop.mjs +7 -0
  16. package/lib/static-assets.mjs +27 -0
  17. package/lib/template-history.json +84 -0
  18. package/lib/worker-build.mjs +13 -4
  19. package/lib/worker-config.mjs +3 -1
  20. package/package.json +1 -1
  21. package/rules/view.ts +1 -1
  22. package/shop/SHOP.md +13 -9
  23. package/worker/analytics.mjs +84 -0
  24. package/worker/app-frame.mjs +1 -1
  25. package/worker/app-graphics.mjs +1 -1
  26. package/worker/app-records.mjs +4 -2
  27. package/worker/app-shell.mjs +9 -4
  28. package/worker/app-streams.mjs +27 -10
  29. package/worker/app-tools.mjs +1 -0
  30. package/worker/asset-paths.mjs +2 -0
  31. package/worker/background-queue.mjs +30 -0
  32. package/worker/build-id.mjs +1 -1
  33. package/worker/built-metadata.mjs +2 -0
  34. package/worker/catalogue.mjs +18 -0
  35. package/worker/chat-page.mjs +1 -1
  36. package/worker/chat-store.mjs +5 -4
  37. package/worker/embed.mjs +1 -1
  38. package/worker/event-resources.mjs +29 -12
  39. package/worker/function-registry.mjs +4 -0
  40. package/worker/functions.mjs +7 -5
  41. package/worker/gate-routing.mjs +61 -0
  42. package/worker/gate.mjs +12 -68
  43. package/worker/hosted-registry.mjs +11 -0
  44. package/worker/hosted.mjs +3 -17
  45. package/worker/index.mjs +58 -2772
  46. package/worker/lazy-objects.mjs +40 -0
  47. package/worker/lobby-limits.mjs +2 -0
  48. package/worker/lobby.mjs +251 -0
  49. package/worker/lounge.mjs +3 -3
  50. package/worker/mcp-event-streams.mjs +3 -3
  51. package/worker/mcp-events.mjs +4 -4
  52. package/worker/mcp-store.mjs +12 -1
  53. package/worker/mcp.mjs +5 -1
  54. package/worker/office.mjs +105 -34
  55. package/worker/operational-schema.mjs +44 -0
  56. package/worker/page-policy.mjs +29 -0
  57. package/worker/pages.mjs +15 -29
  58. package/worker/parts.mjs +2 -1
  59. package/worker/payment-recovery.mjs +33 -19
  60. package/worker/player-registry.mjs +9 -0
  61. package/worker/players.mjs +8 -7
  62. package/worker/purchase-machine.mjs +5 -3
  63. package/worker/reconcile-guard.mjs +22 -0
  64. package/worker/request-db.mjs +78 -0
  65. package/worker/responses.mjs +18 -0
  66. package/worker/room-chat.mjs +216 -0
  67. package/worker/room-controls.mjs +296 -0
  68. package/worker/room-lifecycle.mjs +10 -0
  69. package/worker/room-protocol.mjs +197 -0
  70. package/worker/room.mjs +38 -731
  71. package/worker/rooms-cache.mjs +19 -0
  72. package/worker/routes.mjs +667 -0
  73. package/worker/runtime-support.mjs +445 -0
  74. package/worker/seats.mjs +1 -1
  75. package/worker/selling/index.mjs +1 -0
  76. package/worker/selling/runtime.mjs +7 -5
  77. package/worker/servers.mjs +5 -4
  78. package/worker/service-run.mjs +1 -1
  79. package/worker/shop-links.mjs +2 -2
  80. package/worker/shop-page.mjs +2 -0
  81. package/worker/shop-rules.mjs +3 -2
  82. package/worker/shop-store.mjs +4 -3
  83. package/worker/shop.mjs +89 -27
  84. package/worker/stats.mjs +7 -6
  85. package/worker/stream-objects.mjs +18 -0
  86. package/worker/stripe-intake.mjs +79 -0
  87. package/worker/stripe.mjs +3 -0
  88. package/worker/table.mjs +1212 -0
  89. package/worker/tool-registry.mjs +10 -0
  90. package/worker/version.mjs +1 -1
package/lib/shop-mcp.mjs CHANGED
@@ -65,11 +65,17 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
65
65
  found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
66
66
  }
67
67
  }
68
+ if (!found && method==='POST' && path==='/v1/products' && params.id) {
69
+ const existing=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===`/v1/products/${params.id}`);
70
+ if (!existing) throw {next:{method:'GET',path:`/v1/products/${params.id}`,params:{expand:['default_price']}}};
71
+ if (existing.body?.id===params.id) return existing.body;
72
+ if (existing.body?.error?.code!=='resource_missing') throw new Error('Stripe product lookup failed; resolve the read before creating a product');
73
+ }
68
74
  if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
69
75
  let body=found.body;
70
76
  if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
71
77
  const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
72
- for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
78
+ for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST' || (o.method??'GET')==='GET' && /^\/v1\/products\/[^/]+$/.test(o.path) && o.body?.id) {
73
79
  if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
74
80
  if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
75
81
  }
package/lib/shop.mjs CHANGED
@@ -50,6 +50,8 @@ export const HOOK_EVENTS = Object.freeze([
50
50
  ]);
51
51
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
52
52
  export const KEY_PERMISSIONS = Object.freeze([
53
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
54
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
53
55
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
54
56
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
55
57
  ['PaymentIntents', 'Write', 'verify, create and cancel machine payments as well as read purchase receipts'],
@@ -70,6 +72,11 @@ export const SHOP_HOOK_EVENTS = Object.freeze([
70
72
  ]);
71
73
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
72
74
  export const SHOP_KEY_PERMISSIONS = Object.freeze([
75
+ ['Subscriptions', 'Read', 'verify current subscription items and paid periods'],
76
+ ['Invoices', 'Read', 'verify subscription renewal payments'],
77
+ ['Customer portal', 'Write', 'let shop buyers manage their subscription'],
78
+ ['Products', 'Read', 'read expanded products on checkout and subscription lines'],
79
+ ['Prices', 'Read', 'read prices on checkout and subscription lines'],
73
80
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
74
81
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
75
82
  ['PaymentIntents', 'Read', 'which payment an order was'],
@@ -0,0 +1,27 @@
1
+ /** Assets matching a protected namespace must pass through runtime authorization. */
2
+ export const WORKER_ROUTES = [
3
+ '/', '/index.html', '/api/*', '/_studio/*', '/mcp', '/mcp/*', '/oauth/*', '/hooks/*',
4
+ '/.well-known/*', '/_site/*', '/parts/*', '/games/*', '/account', '/account/*',
5
+ '/shop', '/shop/*', '/lounge', '/lounge/*', '/_homie/*', '/media/*',
6
+ '/robots.txt', '/sitemap.xml', '/llms.txt', '/llms-full.txt',
7
+ ];
8
+ export function staticWorkerRoutes(catalogue) {
9
+ if (catalogue?.studio?.concept === true) return true;
10
+ return [...WORKER_ROUTES, ...(catalogue?.games ?? []).flatMap(game =>
11
+ [`/${game.id}`, `/${game.id}/*`, ...(game.kind === 'app' ? [] : [`!/games/${game.id}/assets/*`, `!/games/${game.id}/_landing/*`])])];
12
+ }
13
+ /** Keep the owner's header rules last. Worker responses attach their own headers. */
14
+ export function staticHeaders(custom = '', {concept = false} = {}) {
15
+ custom = custom.replace(/# BEGIN HOMIE HEADERS[\s\S]*?# END HOMIE HEADERS\n?/g, '');
16
+ return `# BEGIN HOMIE HEADERS
17
+ # Studio defaults: public static assets are served without a Worker invocation.
18
+ /*
19
+ Cache-Control: public, max-age=0, must-revalidate, no-transform
20
+ X-Frame-Options: DENY${concept ? "\n X-Robots-Tag: noindex, nofollow" : ""}
21
+ /games/*
22
+ ! X-Frame-Options
23
+ /parts/*
24
+ ! X-Frame-Options
25
+ # END HOMIE HEADERS
26
+ ${custom}`;
27
+ }
@@ -3963,6 +3963,90 @@
3963
3963
  }
3964
3964
  }
3965
3965
  }
3966
+ },
3967
+ "0.54.0": {
3968
+ "files": {
3969
+ "CLAUDE.md": "ddf2b2a0376202f6",
3970
+ "README.md": "684b3cca8001673c",
3971
+ "HANDOFF.md": "29337e2ace95b2e7",
3972
+ "games/README.md": "5ad32fe80c61f117",
3973
+ "music/README.md": "a3dc042dadd39df8",
3974
+ "videos/README.md": "16babc3437d5545b",
3975
+ "posts/README.md": "37841aae476bd540",
3976
+ "site/README.md": "1dbdf22c03a1e505",
3977
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3978
+ },
3979
+ "sections": {
3980
+ "AGENTS.md": {
3981
+ "(intro)": "394cbbe934e80722",
3982
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3983
+ "## Layout": "f3a280b8c96cd068",
3984
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3985
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3986
+ "## The site": "0cb8dd9a6f8a0096",
3987
+ "## Making games": "bb715272fd1a615b",
3988
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3989
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3990
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3991
+ "## Servers and AI seats": "1ccfd549d2369a60",
3992
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3993
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3994
+ "## Rules": "b2234d80f6cbe71d",
3995
+ "## Telling Homie": "80b46fdd9e578db8",
3996
+ "## Beta": "9d02638207a26e73"
3997
+ }
3998
+ },
3999
+ "named": {
4000
+ "Homie Arcade": {
4001
+ "sections": {
4002
+ "AGENTS.md": {
4003
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4004
+ }
4005
+ }
4006
+ }
4007
+ }
4008
+ },
4009
+ "0.55.0": {
4010
+ "files": {
4011
+ "CLAUDE.md": "ddf2b2a0376202f6",
4012
+ "README.md": "684b3cca8001673c",
4013
+ "HANDOFF.md": "29337e2ace95b2e7",
4014
+ "games/README.md": "5ad32fe80c61f117",
4015
+ "music/README.md": "a3dc042dadd39df8",
4016
+ "videos/README.md": "16babc3437d5545b",
4017
+ "posts/README.md": "37841aae476bd540",
4018
+ "site/README.md": "1dbdf22c03a1e505",
4019
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4020
+ },
4021
+ "sections": {
4022
+ "AGENTS.md": {
4023
+ "(intro)": "394cbbe934e80722",
4024
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4025
+ "## Layout": "f3a280b8c96cd068",
4026
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
4027
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
4028
+ "## The site": "0cb8dd9a6f8a0096",
4029
+ "## Making games": "bb715272fd1a615b",
4030
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4031
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4032
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4033
+ "## Servers and AI seats": "1ccfd549d2369a60",
4034
+ "## Selling things (the shop)": "0df5a5fe1833c624",
4035
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4036
+ "## Rules": "b2234d80f6cbe71d",
4037
+ "## Telling Homie": "80b46fdd9e578db8",
4038
+ "## Beta": "9d02638207a26e73"
4039
+ }
4040
+ },
4041
+ "named": {
4042
+ "Homie Arcade": {
4043
+ "sections": {
4044
+ "AGENTS.md": {
4045
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
4046
+ }
4047
+ }
4048
+ }
4049
+ }
3966
4050
  }
3967
4051
  }
3968
4052
  }
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {createHash} from 'node:crypto';
2
3
  import {refreshWorkerConfig} from './worker-config.mjs';
3
4
  import {pathToFileURL} from 'node:url';
@@ -7,7 +8,7 @@ import {readConfig} from './routes.mjs';
7
8
  import {PACKAGE_ROOT,workerDir} from './studio.mjs';
8
9
  import {paidReleases} from './parts-upload.mjs';
9
10
  /** Preserve dynamic imports as real Worker modules: page startup never evaluates MCP libraries. */
10
- export async function buildWorker(root,esbuild) {
11
+ export async function buildWorker(root,esbuild,metadata=null) {
11
12
  const source=join(root,'site/src/worker.mjs');if(!existsSync(source))return;
12
13
  const out=join(root,'site/src/runtime');
13
14
 
@@ -17,8 +18,16 @@ export async function buildWorker(root,esbuild) {
17
18
  if(paidReleases(root).length || existsSync(join(root,'shop.json')) || pricedTools)alias['@homie-rocks/studio/worker']='@homie-rocks/studio/worker/selling';
18
19
  // Older studio entries export only Table and Lobby. Supply the new toolkit-owned
19
20
  // classes in the generated bundle without rewriting the studio's source/customizations.
20
- const contents=`export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/gate.mjs'))};\n`;
21
- const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent'});
21
+ const standardRooms=/export\s*\{[^}]*\bTable\b[^}]*\bLobby\b[^}]*\}\s*from\s*['"]@homie-rocks\/studio\/worker['"]/.test(readFileSync(source,'utf8'));
22
+ const lazyRooms=standardRooms?`export {Table,Lobby} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`:'';
23
+ const contents=`${lazyRooms}export * from ${JSON.stringify(source)};\nexport { default } from ${JSON.stringify(source)};\nexport { Gate, Concentrator } from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\nexport {AppRecordHub, EventHub} from ${JSON.stringify(join(PACKAGE_ROOT,'worker/lazy-objects.mjs'))};\n`;
24
+ const result=await esbuild.build({stdin:{contents,resolveDir:root,sourcefile:'worker.mjs'},entryNames:'worker',absWorkingDir:root,alias,outdir:out,bundle:true,splitting:true,format:'esm',platform:'browser',mainFields:['module','main'],conditions:['workerd','worker','browser'],external:['node:*','cloudflare:*'],target:'es2022',write:false,minify:true,metafile:true,logLevel:'silent',plugins:[{name:'studio-runtime',setup(build){
25
+ if(metadata)build.onLoad({filter:/[/\\]worker[/\\]built-metadata\.mjs$/},()=>({contents:'export default '+JSON.stringify(metadata),loader:'js'}));
26
+ // Merely overriding entry exports still evaluates the barrel's dependencies.
27
+ // Standard generated rooms use lazy exports at that barrel as well; a studio
28
+ // with custom subclasses retains the original class constructors.
29
+ if(standardRooms)build.onLoad({filter:/[/\\]worker[/\\]index\.mjs$/},({path})=>path===join(PACKAGE_ROOT,'worker/index.mjs')?{contents:readFileSync(path,'utf8').replace("export {Table} from './table.mjs';","export {Table} from './lazy-objects.mjs';").replace("export {Lobby} from './lobby.mjs';","export {Lobby} from './lazy-objects.mjs';").replace("export { Gate, Concentrator } from './gate.mjs';","export { Gate, Concentrator } from './lazy-objects.mjs';").replace("export {AppRecordHub, EventHub} from './stream-objects.mjs';","export {AppRecordHub, EventHub} from './lazy-objects.mjs';"),loader:'js'}:undefined);
30
+ }}]});
22
31
  // A watcher may already have read the previous entry while this build is
23
32
  // publishing. Keep its entire graph available. Publish immutable modules first,
24
33
  // then switch the single entry atomically; never remove a running graph.
@@ -41,7 +50,7 @@ export async function buildWorker(root,esbuild) {
41
50
  }
42
51
  // Wrangler's module root is the immutable generation, so old generations are
43
52
  // retained locally for in-flight reloads but are never added to a deployment.
44
- refreshWorkerConfig(root);
53
+ refreshWorkerConfig(root, metadata ? {workerRoutes: staticWorkerRoutes(metadata.catalogue)} : {});
45
54
  const current=join(out,'current.json'), pending=`${current}.${process.pid}.tmp`;
46
55
  writeFileSync(pending,JSON.stringify({main:join(dir,'worker.js')})+'\n');renameSync(pending,current);
47
56
  // Dev sometimes watches a copy with routes/remote AI removed. Publish its
@@ -1,3 +1,4 @@
1
+ import {staticWorkerRoutes} from './static-assets.mjs';
1
2
  import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
3
  import {readFileSync,writeFileSync,existsSync,renameSync} from 'node:fs';
3
4
  import {join} from 'node:path';
@@ -27,6 +28,7 @@ export function updateWorkerConfig(root,generated) {
27
28
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
28
29
  }
29
30
  const next=merge(base,custom);
31
+ if(readStudio(root).concept===true)next.assets={...next.assets,run_worker_first:true};
30
32
  next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
31
33
  verifiedMcpConfig(root);
32
34
  if(publicMcpSetup(root)) {
@@ -48,5 +50,5 @@ export function refreshWorkerConfig(root,overrides={}) {
48
50
  const tools=read(join(root,'site/src/tools/declarations.json'));
49
51
  const ai=cf.ai??listExperiences(root).some(g=>g.decide===true || g.chat!==false && (g.chat?.mode??'text')==='text' && g.chat?.ai!==false);
50
52
  const paid=paidReleases(root).length>0||existsSync(join(root,'shop.json'))||tools.paid===true;
51
- updateWorkerConfig(root,wranglerConfig({worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
53
+ updateWorkerConfig(root,wranglerConfig({analytics:studio.stats?.analytics===true,workerRoutes:staticWorkerRoutes(read(join(root,'site/dist/games.json'))),worker:cf.worker,name:studio.name,d1:cf.d1,d1Id:cf.d1Id,r2:cf.r2,layout:layoutOf(root),ai,paidParts:paid,partsRateLimit:cf.purchaseRateLimit??null,routes:cf.routes??null,eventFeeds:studio.mcp?.events,triggers:{crons:[scheduleTrigger(functions,{eventFeeds:Object.keys(studio.mcp?.events??{}).length>0})].filter(Boolean)},...overrides}));
52
54
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.53.0",
3
+ "version": "0.55.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/view.ts CHANGED
@@ -1314,7 +1314,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
1314
1314
  get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
1315
1315
  get status() { return solo ? 'offline' : status; },
1316
1316
  get seat() { return solo ? solo.seat : net.seat; },
1317
- get me() { pump(); return meNow(true); },
1317
+ get me() { if (!presentedEntity) pump(); return meNow(true); },
1318
1318
  each, get,
1319
1319
  ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
1320
1320
  rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
package/shop/SHOP.md CHANGED
@@ -305,7 +305,7 @@ await shop.checkout({ reference: 'table-7' });
305
305
  The default opens the synced Stripe menu containing those items. The guest chooses
306
306
  final quantities **on Stripe**, starting at one for each menu line; the local cart's
307
307
  quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
308
- removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
308
+ removing the last item); maximum is the item’s `maxQuantity` from shop.json, default 99 (a positive integer up to 999999). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
309
309
  if selected items span menus, the app receives `menu` and should offer separate
310
310
  checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
311
311
  Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
@@ -329,8 +329,7 @@ the owner's AI write the complete manifest to `.studio/booking-slots.json` and r
329
329
 
330
330
  Sync after publishing or changing slots. Removed or inactive slots deactivate their
331
331
  links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
332
- fixed service and one seat per checkout. Re-sync preserves Stripe's lifetime completed
333
- count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
332
+ fixed service and an adjustable group quantity up to the published remaining seats. Stripe counts completed sessions, not seats: supply the reviewed `confirmed` seat total when resyncing. The database confirms the paid quantity atomically; a capacity race becomes a visible review order rather than an overbooking. Re-sync preserves Stripe's lifetime completed count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
334
333
  Refunds do not decrement Stripe's completed-session counter; the owner's AI must
335
334
  review availability and resync explicitly. A changed price should use a new slot;
336
335
  do not replace a sold link and accidentally reset its capacity counter.
@@ -441,15 +440,20 @@ stored preset or an empty amount; keep the expected total visible on the phone.
441
440
 
442
441
  After a signed paid session matches the frozen link, revision, mode, currency and
443
442
  order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
444
- payment), `basketTotal` (expected total), and `settlement` (`paid` or `short`).
443
+ payment), `basketTotal` (expected total), and `settlement` (`paid`).
445
444
  Line `amount` is the saved extended amount (unit price × quantity); line `total`
446
445
  is its proportionally allocated share of the actual aggregate payment for refunds.
447
- A short payment emits the event so staff can resolve it, but grants no automatic
448
- entitlements. Apps must compare the actual payment with `basketTotal`, show a
449
- shortfall explicitly, and deduplicate by order ID. More than the total is paid.
450
- Reopening and paying the link creates another order for the same frozen basket;
451
- repeated delivery of the same session never creates another round.
446
+ A payment that differs from the saved basket total emits `payment.review`, retains
447
+ the actual payment, and shows the customer a staff-review notice. It never emits
448
+ `order.paid` or grants entitlements. Staff reconcile or refund the difference.
449
+ Exact payments emit `order.paid`; deduplicate fulfillment by order ID.
452
450
 
453
451
  The Stripe heading is the connected Stripe account's business name. A sandbox
454
452
  account named “Homie sandbox” shows that name; a real pub's account shows the pub's
455
453
  name. The basket's product name and description use the pub's own wording.
454
+
455
+ ### Payments requiring review
456
+
457
+ A signed paid deposit or basket that cannot match its saved order is retained as a `review` order, with the payment ID and actual total, a customer notice and a durable `payment.review` event. Staff reconcile fulfillment or refund in Stripe. Group payments that exceed remaining capacity follow the same path without granting seats. Repeated events do not duplicate the review order.
458
+
459
+ Full connect saves `.studio/stripe-mcp.json` and public purchase configuration for the build’s JSON asset, just like plugin verification. Rebuild/deploy before retiring old links. `STRIPE_CHECKOUT_MODE` stays in public Wrangler vars; only the webhook signing secret and private purchase signing keys use secrets.
@@ -0,0 +1,84 @@
1
+ /** Page counters are sampled analytics, never billing or gameplay state.
2
+ * D1 retains historical totals. Each rollup adds only the delta from the last
3
+ * observed total, in the same transaction as its watermark, so retries are safe.
4
+ */
5
+ export const PAGE_METRICS = new Set(['visit', 'play', 'screen', 'watch']);
6
+ export function writePageMetric(env, row) {
7
+ if (!env.STUDIO_ANALYTICS || !env.STUDIO_ANALYTICS_DATASET || !PAGE_METRICS.has(row.metric)) return false;
8
+ const day = row.day ?? new Date().toISOString().slice(0, 10);
9
+ try { env.STUDIO_ANALYTICS.writeDataPoint({
10
+ indexes: [String(row.subject ?? '').slice(0, 80) || 'studio'],
11
+ blobs: [day, row.metric, String(row.subject ?? '').slice(0, 80), String(row.source ?? '').slice(0, 80)],
12
+ doubles: [Math.max(0, Math.floor(Number(row.n ?? 1) || 0))],
13
+ }); } catch (error) {
14
+ // Analytics is sampled telemetry. A binding failure must not fail a page or
15
+ // amplify the outage into a D1 write on every visit.
16
+ console.warn('Studio analytics point dropped:', error.message);
17
+ }
18
+ return true;
19
+ }
20
+ async function* analyticsPages(env,{now=Date.now(),pageSize=1000}={}) {
21
+ if (!env.ANALYTICS_SQL || !env.STUDIO_ANALYTICS_DATASET) return;
22
+ const dataset=String(env.STUDIO_ANALYTICS_DATASET);
23
+ if(!/^[a-z0-9_-]+$/.test(dataset))throw new Error('Invalid studio analytics dataset');
24
+ // Revisit each UTC day for late ingestion and sample corrections. Ordered
25
+ // pages avoid the aggregate 50k result ceiling, including a busy single day.
26
+ for(let ago=7;ago>=0;ago--) {
27
+ const day=new Date(now-ago*86400000).toISOString().slice(0,10);
28
+ for(let offset=0;;offset+=pageSize) {
29
+ const result=await env.ANALYTICS_SQL.query({
30
+ query:`SELECT blob1 AS day, blob2 AS metric, blob3 AS subject, blob4 AS source, SUM(_sample_interval * double1) AS n
31
+ FROM events.analyticsEngine."${dataset}" WHERE timestamp >= $start AND blob1 = $day
32
+ GROUP BY blob1, blob2, blob3, blob4 ORDER BY blob1, blob2, blob3, blob4 LIMIT ${pageSize} OFFSET $offset`,
33
+ params:{start:day+'T00:00:00Z',day,offset},
34
+ });
35
+ if(!Array.isArray(result.data))throw new Error('Incomplete studio analytics rollup');
36
+ yield result.data.filter(r=>/^\d{4}-\d{2}-\d{2}$/.test(r.day)&&PAGE_METRICS.has(r.metric)&&Number.isFinite(Number(r.n)));
37
+ if(result.data.length<pageSize)break;
38
+ }
39
+ }
40
+ }
41
+ export async function analyticsTotals(env,now=Date.now()) {
42
+ const rows=[];for await(const page of analyticsPages(env,{now}))rows.push(...page);return rows;
43
+ }
44
+ export async function analyticsStatus(env,now=Date.now()) {
45
+ const rows=(await env.DB.prepare("SELECT key,value FROM meta WHERE key IN ('analytics-rollup-error','analytics-rollup-success')").all()).results??[];
46
+ const values=Object.fromEntries(rows.map(r=>[r.key,r.value]));
47
+ const error=values['analytics-rollup-error'];
48
+ return {lastSuccess:Number(values['analytics-rollup-success'])||null,delayed:Boolean(error)||now-Number(values['analytics-rollup-success']??0)>7200000,error:error||null};
49
+ }
50
+ async function health(env,key,value) {
51
+ await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value WHERE value!=excluded.value').bind(key,String(value)).run();
52
+ }
53
+ export async function rollupAnalytics(env,options={}) {
54
+ if(!env.DB||!env.ANALYTICS_SQL)return;
55
+ try {
56
+ for await(const rows of analyticsPages(env,options)) {
57
+ for(let offset=0;offset<rows.length;offset+=40) {
58
+ const statements=rows.slice(offset,offset+40).flatMap(r=>{
59
+ const args=[r.day,r.metric,r.subject,r.source,Math.round(Number(r.n))];
60
+ return [
61
+ env.DB.prepare(`INSERT INTO stats_daily(day,metric,subject,source,n)
62
+ SELECT ?1,?2,?3,?4,?5-COALESCE((SELECT n FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4),0)
63
+ WHERE NOT EXISTS(SELECT 1 FROM stats_analytics_daily WHERE day=?1 AND metric=?2 AND subject=?3 AND source=?4 AND n=?5)
64
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=stats_daily.n+excluded.n`).bind(...args),
65
+ env.DB.prepare(`INSERT INTO stats_analytics_daily(day,metric,subject,source,n) VALUES(?1,?2,?3,?4,?5)
66
+ ON CONFLICT(day,metric,subject,source) DO UPDATE SET n=excluded.n WHERE n!=excluded.n`).bind(...args),
67
+ ];
68
+ });
69
+ if(statements.length)await env.DB.batch(statements);
70
+ }
71
+ }
72
+ await health(env,'analytics-rollup-error','');
73
+ await health(env,'analytics-rollup-success',options.now??Date.now());
74
+ return {delayed:false};
75
+ } catch(error) {
76
+ console.error('Studio analytics counts delayed:',error.message);
77
+ await health(env,'analytics-rollup-error',String(error.message).slice(0,500));
78
+ return {delayed:true,error:error.message};
79
+ }
80
+ }
81
+
82
+ export const ANALYTICS_MIGRATION_FILE="0037_owner_analytics.sql";
83
+ export const ANALYTICS_MIGRATION=`CREATE TABLE IF NOT EXISTS stats_analytics_daily(day TEXT NOT NULL,metric TEXT NOT NULL,subject TEXT NOT NULL,source TEXT NOT NULL,n INTEGER NOT NULL,PRIMARY KEY(day,metric,subject,source)) WITHOUT ROWID;
84
+ `;
@@ -1,7 +1,7 @@
1
1
  /** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
2
2
  export const APP_STORAGE_JS=String.raw`(()=>{
3
3
  for(const name of ['localStorage','sessionStorage']) {
4
- try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
4
+ try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{/* A blocked storage probe falls through to the in-memory shim. */}
5
5
  const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
6
6
  const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
7
7
  Object.defineProperty(window,name,{value:storage,configurable:true});
@@ -1,6 +1,6 @@
1
1
  export function appGraphicsFallback(text='This device cannot display the 3D scene. Use the text controls to continue.') {
2
2
  if(!document.querySelector('canvas')||document.querySelector('[data-webgl-fallback]'))return;
3
- try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{}
3
+ try{const c=document.createElement('canvas');if(c.getContext('webgl2'))return;}catch{/* Context creation can throw when graphics are unavailable; show the fallback. */}
4
4
  const p=document.createElement('p');p.dataset.webglFallback='';p.setAttribute('role','status');p.textContent=text;
5
5
  p.style.cssText='position:static;pointer-events:none;padding:12px;background:#fff;color:#222;font:16px system-ui;border-radius:12px';
6
6
  const canvas=document.querySelector('canvas');
@@ -4,9 +4,9 @@ import { moveBooking } from './booking-store.mjs';
4
4
  import { toolIdentity } from './tool-identity.mjs';
5
5
  /** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
6
6
  import { appRole, recordProblem } from './app-format.mjs';
7
- import { isOwner, sameOrigin } from './office.mjs';
7
+ import { appAssetTicket, isOwner, sameOrigin, launchOf, settingsOf } from './office.mjs';
8
8
  import { players } from './players.mjs';
9
- import { ownerAllowed } from './stats.mjs';
9
+ import { cookieValues, ownerAllowed } from './stats.mjs';
10
10
  import { appOriginOf } from './standalone.mjs';
11
11
  export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
12
12
  export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
@@ -52,6 +52,8 @@ export async function appAccess(request, env, meta, url) {
52
52
  const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
53
53
  return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
54
54
  }
55
+ const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
56
+ if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
55
57
  const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
56
58
  if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
57
59
  if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
@@ -4,7 +4,12 @@ export const APP_SHELL_JS = String.raw`(function () {
4
4
  if (!boot || boot.kind !== 'app') return;
5
5
  var frame = boot.direct ? {contentWindow:window} : document.querySelector('iframe.game') || document.querySelector('iframe');
6
6
  var params = new URLSearchParams(location.search);
7
- // Share short-lived reads across same-origin screens with the exact same role/access.
7
+ // Authorized shell responses have already set the HttpOnly role handoff.
8
+ if(params.has('access')) {
9
+ params.delete('access');
10
+ history.replaceState(null,'',location.pathname+(params.size?'?'+params:'')+(location.hash||''));
11
+ }
12
+ // Share short-lived reads across same-origin screens with the same role and browser cookies.
8
13
  // No records are persisted. Writes always reach the Worker and invalidate all views.
9
14
  var recent = new Map(), failures = new Map(), pending = new Map();
10
15
  var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
@@ -38,11 +43,11 @@ export const APP_SHELL_JS = String.raw`(function () {
38
43
  var live, collections=new Set(), presence=0, clock=null;
39
44
  function query() {
40
45
  var q=new URLSearchParams();
41
- ['role','surface','access','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
46
+ ['role','surface','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
42
47
  if(boot.screen&&!q.has('role'))q.set('surface','wall');
43
48
  return q;
44
49
  }
45
- function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
50
+ function context() {var room=new URLSearchParams(location.search).get('room');return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open'+(room&&/^[A-Za-z0-9_-]{1,32}$/.test(room)?'?room='+encodeURIComponent(room):''),presence:presence,now:clock};}
46
51
  function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
47
52
  function connect(){
48
53
  live?.close();
@@ -63,7 +68,7 @@ export const APP_SHELL_JS = String.raw`(function () {
63
68
  if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
64
69
  if (m.type==='homie-app' && (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record)))) return;
65
70
  var q = new URLSearchParams();
66
- ['role','surface','access'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
71
+ ['role','surface'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
67
72
  if (boot.screen && !q.has('role')) q.set('surface', 'wall');
68
73
  var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
69
74
  if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
@@ -1,3 +1,4 @@
1
+ import {databaseBinding} from './request-db.mjs';
1
2
  import {appAccess} from './app-records.mjs';
2
3
  import {localSession} from './local-test.mjs';
3
4
  import {settingsOf,launchOf,accessOf} from './office.mjs';
@@ -9,16 +10,29 @@ ${['INSERT','UPDATE','DELETE'].map(op=>{const row=op==='DELETE'?'OLD':'NEW';retu
9
10
  END;`;}).join('\n')}
10
11
  `;
11
12
  const frame=value=>new TextEncoder().encode('data: '+JSON.stringify(value)+'\n\n');
12
- export const appHub=(env,app)=>env.LOBBY.get(env.LOBBY.idFromName('@app-records/'+app));
13
+ export const appHub=(env,app)=>{const ns=env.APP_RECORDS??env.LOBBY;return ns.get(ns.idFromName('@app-records/'+app));};
14
+ const pushedRevisions=new WeakMap();
13
15
  export async function pushAppRecords(env,cat){
14
- if(!env.LOBBY||!env.DB)return;
15
- await Promise.all((cat.games??[]).filter(g=>g.kind==='app').map(meta=>appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})}).catch(error=>console.error('App notification wake failed',error))));
16
+ const apps=(cat.games??[]).filter(g=>g.kind==='app');
17
+ if(!(env.APP_RECORDS??env.LOBBY)||!env.DB||!apps.length)return;
18
+ const rows=(await env.DB.prepare(`SELECT app,collection,revision FROM app_record_revisions
19
+ UNION ALL SELECT a.app,'*',SUM(COALESCE(r.revision,0)) FROM app_tool_namespaces a LEFT JOIN tool_data_revisions r ON r.namespace=a.namespace GROUP BY a.app`).all()).results;
20
+ const signatures=new Map();for(const row of rows){const app=row.app.split(':check:')[0];signatures.set(app,(signatures.get(app)??'')+JSON.stringify(row));}
21
+ const binding=databaseBinding(env.DB),previous=pushedRevisions.get(binding)??new Map();
22
+ for(const meta of apps){
23
+ const signature=signatures.get(meta.id)??'';
24
+ if((previous.get(meta.id)??'')===signature)continue;
25
+ const response=await appHub(env,meta.id).fetch('https://app/__app_records',{method:'POST',body:JSON.stringify({op:'wake',meta})});
26
+ if(!response.ok)throw new Error('App notification wake failed');
27
+ previous.set(meta.id,signature);
28
+ }
29
+ pushedRevisions.set(binding,previous);
16
30
  }
17
31
  /** Collection invalidations, never record bodies or identifiers. Reads recheck current authorization. */
18
32
  export class AppRecordStreams {
19
33
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
20
34
  dispose(){for(const s of [...this.streams])this.close(s);}
21
- close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);try{s.controller.close();}catch{}this.presence();}
35
+ close(s){if(!this.streams.delete(s))return;clearInterval(s.timer);clearTimeout(s.reconnect);try{s.controller.close();}catch{/* The reader already cancelled or the stream already closed. */}this.presence();}
22
36
  count(s){return [...this.streams].filter(x=>x.app===s.app&&x.room===s.room).length;}
23
37
  send(s,data){if(s.controller.desiredSize<=0){this.close(s);return;}try{s.controller.enqueue(frame(data));}catch{this.close(s);}}
24
38
  presence(){for(const s of this.streams)this.send(s,{presence:this.count(s)});}
@@ -28,7 +42,7 @@ export class AppRecordStreams {
28
42
  const launch=launchOf(s.meta,await settingsOf(this.env,{fresh:true}),this.env);
29
43
  return launch==='public'||(await accessOf(s.request,this.env,s.meta.id,launch)).ok;
30
44
  }
31
- async revisions(s){return (await this.env.DB.prepare('SELECT collection,revision FROM app_record_revisions WHERE app=?1').bind(s.app).all()).results;}
45
+ async revisions(s){return (await this.env.DB.prepare("SELECT collection,revision FROM app_record_revisions WHERE app=?1 UNION ALL SELECT '*',COALESCE(SUM(r.revision),0) FROM app_tool_namespaces a JOIN tool_data_revisions r ON r.namespace=a.namespace WHERE a.app=?1").bind(s.app).all()).results;}
32
46
  async open({meta,url,headers,collections=[]}){
33
47
  if(!Array.isArray(collections)||collections.some(c=>typeof c!=='string'||!Object.hasOwn(meta.records?.collections??{},c)))return new Response('unknown collection',{status:400});
34
48
  const s={meta,url:new URL(url),request:new Request(url,{headers}),collections,seen:new Map()};
@@ -40,17 +54,20 @@ export class AppRecordStreams {
40
54
  const body=new ReadableStream({start:controller=>{s.controller=controller;},cancel:()=>this.close(s)});
41
55
  this.streams.add(s);this.send(s,{collections:s.collections,presence:this.count(s),reset:true});
42
56
  for(const other of this.streams)if(other!==s)this.send(other,{presence:this.count(other)});
57
+ // A fresh HTTP response renews the role cookie; SSE frames cannot set cookies.
58
+ s.reconnect=setTimeout(()=>this.close(s),240000);
43
59
  // Recovery after a missed wake; this is not the screen's refresh clock.
44
- s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s)),15000);
60
+ s.timer=setInterval(()=>this.ctx.waitUntil(this.pump(s,true)),15000);
45
61
  return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
46
62
  }
47
- async pump(s){
63
+ async pump(s,recheck=false){
48
64
  if(!this.streams.has(s))return;if(s.busy){s.dirty=true;return;}s.busy=true;
49
65
  try{
66
+ const changed=new Set();
67
+ for(const row of await this.revisions(s)){if(s.seen.get(row.collection)!==row.revision){if(row.collection==='*')for(const c of s.collections)changed.add(c);else if(s.collections.includes(row.collection))changed.add(row.collection);}s.seen.set(row.collection,row.revision);}
68
+ if(!changed.size&&!recheck)return;
50
69
  if(!await this.authorized(s)){this.close(s);return;}
51
- const changed=[];
52
- for(const row of await this.revisions(s)){if(s.collections.includes(row.collection)&&s.seen.get(row.collection)!==row.revision)changed.push(row.collection);s.seen.set(row.collection,row.revision);}
53
- this.send(s,{collections:changed,presence:this.count(s)});
70
+ this.send(s,{collections:[...changed],presence:this.count(s)});
54
71
  }catch{this.close(s);}finally{s.busy=false;if(s.dirty){s.dirty=false;await this.pump(s);}}
55
72
  }
56
73
  async wake(meta){for(const s of this.streams)if(meta)s.meta=meta;await Promise.all([...this.streams].map(s=>this.pump(s)));return new Response('ok');}
@@ -37,6 +37,7 @@ export async function appToolsRoute(request,env,meta,url,cat,definitions){
37
37
  if(tool.price)return fail(402,'Use MCP to approve payment for this tool');
38
38
  if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))return fail(429,'Rate limit reached');
39
39
  const namespace=(tool.namespace??tool.name)+(caller.testScope?':check:'+caller.testScope:'');
40
+ await env.DB.prepare('INSERT OR IGNORE INTO app_tool_namespaces(app,namespace) VALUES(?1,?2)').bind(meta.id+(caller.testScope?':check:'+caller.testScope:''),namespace).run();
40
41
  const data=await invokeAppTool(tool,body.input??{},services(env,cat,url.origin,caller,namespace,tool));
41
42
  outcome='ok';return Response.json({ok:true,data});
42
43
  }catch(error){return fail(error.status??(error instanceof SyntaxError?400:500),error.status||error.name==='StudioOperationError'?error.message:'Tool request failed');}
@@ -0,0 +1,2 @@
1
+ export const GAME_FILES = /^\/[a-z0-9][a-z0-9-]{0,39}\/(?:__game|__assets)(?:\/|$)/;
2
+ export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
@@ -0,0 +1,30 @@
1
+ import {catalogue} from './catalogue.mjs';
2
+ /** Durable rows remain the source of truth. Messages carry wakeups, never records. */
3
+ export async function enqueueChanges(env, origin) {
4
+ if(env.BACKGROUND) {
5
+ try {return await env.BACKGROUND.send({v:1,type:'studio.changed',origin});}
6
+ catch(error) {console.warn('Background queue unavailable; delivering inline:',error.message);}
7
+ }
8
+ // Older wrappers and local unit fixtures without the binding keep their behavior.
9
+ return (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
10
+ }
11
+ export async function consumeBackground(batch, env) {
12
+ const changes=batch.messages.filter(m=>m.body?.v===1&&m.body.type==='studio.changed');
13
+ if(changes.length) {
14
+ try {
15
+ const origin=changes[0].body.origin;
16
+ await (await import('./functions.mjs')).runFunctions(env,()=>catalogue(env,origin),origin);
17
+ for(const message of changes)message.ack();
18
+ } catch(error) {
19
+ console.error('Background delivery failed',error);
20
+ for(const message of changes)message.retry({delaySeconds:30});
21
+ }
22
+ }
23
+ for(const message of batch.messages)if(!changes.includes(message)) {
24
+ try {
25
+ if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
26
+ await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
27
+ message.ack();
28
+ } catch(error) {console.error('Background queue delivery failed',error);message.retry({delaySeconds:error.retryAfterSeconds??30});}
29
+ }
30
+ }
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.53.0","sha256":"c43b8fcfbd61ff241c86555b2806cd599baf71513376d3958c379b04697dd923"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.55.0","sha256":"597d65592bde7e9519b8184bc2db2502a88ef37cfc31dd6ba3b1c9a4ece30132"});
@@ -0,0 +1,2 @@
1
+ // Replaced with this build’s immutable metadata by lib/worker-build.mjs.
2
+ export default null;
@@ -0,0 +1,18 @@
1
+ const cache = new WeakMap();
2
+ /** Cache completed immutable data only. Never share pending I/O across requests. */
3
+ export async function catalogue(env, origin = 'https://studio.invalid') {
4
+ const baked = (await import('./built-metadata.mjs')).default;
5
+ if (baked) return baked.catalogue;
6
+ const hit = cache.get(env.ASSETS);
7
+ if (hit?.origin === origin && env.HOMIE_PREVIEW !== '1' && env.HOMIE_DEV !== '1') return hit.value;
8
+ const response = await env.ASSETS.fetch(new Request(`${origin}/games.json`));
9
+ if (!response.ok) return {studio: {name: env.STUDIO_NAME || 'Studio'}, games: []};
10
+ const tag=response.headers.get('etag');
11
+ if(tag&&hit?.origin===origin&&hit.tag===tag){await response.body?.cancel();return hit.value;}
12
+ const value = await response.json();
13
+ cache.set(env.ASSETS, {origin, value, tag});
14
+ return value;
15
+ }
16
+ export async function builtPosts() {
17
+ return (await import('./built-metadata.mjs')).default?.posts;
18
+ }