@homie-rocks/studio 0.53.0 → 0.54.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 CHANGED
@@ -17,6 +17,36 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.54.0] - 2026-10-15
21
+
22
+ **Plugin 0.54.0**
23
+
24
+ Studios answer faster, private apps stay private and usable, shops catch every payment, and the toolkit's CI costs a quarter of what it did.
25
+
26
+ ### Changed
27
+
28
+ - Reduce pull-request CI work through shared studio fixtures, focused compatibility coverage, and nightly release checks.
29
+
30
+ ### Fixed
31
+
32
+ - **Long-running app screens:** renew role handoffs on records, live and tools responses, reconnect idle live streams before expiry, and set handoffs on records-only pages. Revoked sessions, grants and rotated links still stop access. Remove the shared role secret from the phone address bar and subsequent API URLs.
33
+ - **Tool catalogue:** load and notify once per Worker isolate and database binding, then refresh after studio mutations; static requests no longer repeatedly load tool modules and serialize schemas.
34
+ - **Test assets:** require a valid test session for the same app, role and scope whenever a test-kind asset ticket is used.
35
+
36
+ - **Request path:** remove referral cleanup and function registration from startup; clean retired referral keys in migration 0031. Reuse the deployment catalogue, reuse stable-binding metadata caches guarded by a trigger-maintained revision, avoid repeat tool-catalogue notifications, and dispatch background work after actual writes. Public static assets require no D1 reads; private app assets retain live authorization.
37
+ - **Payment Links:** menu items support `maxQuantity` (default 99), slot links accept groups within published remaining capacity, and confirmation uses the paid seat quantity atomically. Payments that cannot be fulfilled remain visible as review orders with a durable staff event and customer notice. Full connect saves public JSON link assets, retaining replaced links until deployment.
38
+ - **Stripe setup:** observe deterministic product IDs before creating missing search results; keep public checkout mode out of secret uploads and report Wrangler's redacted error text.
39
+ - **Payment review:** single-item links enforce quantity caps at Stripe and fulfillment; old menu snapshots retain their published maximum. Signed payments with missing/unknown references or a currency mismatch remain visible for staff review without granting entitlements.
40
+ - **Background delivery:** include nested background work, preserve D1 sessions and raw-write tracking, and refresh connected AIs’ tool lists on the first ordinary request after deployment.
41
+ - **App checks:** needs without assertions are pending placeholders, rather than failed checks. They remain unproven: check reports not done with a pending label while preserving a nonfailing exit for placeholders.
42
+ - **App navigation:** Big screen uses a five-minute HttpOnly same-origin role session instead of copying the shared access secret; app context URLs include only a validated public room ID.
43
+ - **App subscriptions and privacy:** tool-data revisions and app wakes are scoped to the namespaces each app uses. Private asset capabilities support invite passes, signed-in sessions and tool/test identities, conceal session hashes with encryption plus HMAC, and recheck revocation and grants in one statement.
44
+ - **Prediction:** synchronous `room.me` readers share a presentation even when CPU work crosses a simulation tick; the next microtask refreshes it. Fixes the 60 Hz browser prediction smoke flake without relaxing thresholds.
45
+
46
+ ### Upgrade notes
47
+
48
+ - Apply migrations 0031–0033 with the normal studio deploy. Rebuild/deploy after full `shop connect` to publish its public link configuration. Review group availability and resync slot manifests after confirmations, moves or refunds; a Payment Link is not an exclusive unpaid hold.
49
+
20
50
  ## [0.53.0] - 2026-10-15
21
51
 
22
52
  **Plugin 0.53.0**
@@ -1394,7 +1394,7 @@ try {
1394
1394
  // A shared progress feed sends its last word before the command exits.
1395
1395
  await flushProgress();
1396
1396
  if (result && result.command !== 'help') print(result);
1397
- if (result?.ok === false) process.exitCode = 1;
1397
+ if (result?.ok === false) process.exitCode = result.command==='check' && result.pending===true && result.exitCode===0 ? 0 : 1;
1398
1398
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
1399
1399
  if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1400
1400
  } catch (error) {
@@ -101,9 +101,9 @@ The Codex's `needs` fenced block is a JSON array, one entry per need from the re
101
101
  ]
102
102
  ```
103
103
 
104
- Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it not met. Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
104
+ Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it pending (a placeholder, not a failure or proof). Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
105
105
 
106
- Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
106
+ Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not/pending, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
107
107
 
108
108
  For staff flows, sign in through the existing local owner link and prove the transition in real Chrome. Keep evidence in the Codex alongside the automated flows. Confirm unauthorized writes are refused and records remain after all screens close. Never call an untested privileged path passed. Before final handover, show the need table with evidence and explicit remaining gaps. If the starter or a board of boxes is still visible, return to the scene design and build it.
109
109
 
@@ -210,3 +210,9 @@ and Stripe CLI `listen --all-snapshot --forward-to http://localhost:8787/api/sho
210
210
  recipe is in the installed toolkit’s `apps/APPS.md`, “Real Stripe test checkout on
211
211
  this computer”. It uses root `.dev.vars`, needs no public HTTPS or Cloudflare, and
212
212
  keeps the existing approved Stripe CLI session. Do not write a stand-in checkout.
213
+
214
+ ### Batch setup and reconciliation
215
+
216
+ Set each menu item's `maxQuantity` in `shop.json` when the studio has an order limit; the default is 99. Slot links support group quantities up to published remaining seats. Supply reviewed `confirmed` seat totals when resyncing: Stripe counts sessions, not people. The database confirms the entire paid group atomically. If a payment differs from the saved deposit/basket or a group no longer fits, inspect the `review` order and `payment.review` event, arrange fulfillment or refund, and show the customer the saved notice. Never discard a paid event.
217
+
218
+ Full `shop connect` and plugin verification both save public JSON assets; rebuild/deploy those assets before retiring replaced links. Checkout mode is a public Worker variable, not a secret. If a product search misses an existing deterministic ID, perform the planner's GET for that ID before creating it. Preserve Wrangler's diagnostic when setup fails.
package/lib/app-needs.mjs CHANGED
@@ -45,7 +45,14 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
45
45
  let waiting = 'validating assertions';
46
46
  const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
47
47
  try {
48
- if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
48
+ if (need.assertions === undefined || Array.isArray(need.assertions) && !need.assertions.length) {
49
+ result.status = 'pending';
50
+ result.why = 'Placeholder: add assertions to prove this need';
51
+ report?.check?.(`need-${need.id}`, 'skip', { label: need.statement, note: result.why });
52
+ results.push(result);
53
+ continue;
54
+ }
55
+ if (!Array.isArray(need.assertions)) throw new Error('assertions must be an array');
49
56
  const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
50
57
  for(const item of [...needSteps(need.steps),...need.assertions])if(item.surface&&!popupNames.has(item.surface))surface(item.surface,item.role);
51
58
  for(const target of new Set(need.assertions.filter(a=>a.effect).map(a=>surface(a.surface,a.role))))await target.frame.evaluate(()=>{window.__homieNeedsEffects=[];});
package/lib/build.mjs CHANGED
@@ -689,7 +689,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
689
689
  mkdirSync(join(dist, '_site'), { recursive: true });
690
690
  if(publicMcpSetup(root)) {
691
691
  const verified=verifiedMcpConfig(root);
692
- writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
692
+ writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,purchases:existsSync(join(root,'.studio','stripe-purchase-links.json'))?JSON.parse(readFileSync(join(root,'.studio','stripe-purchase-links.json'),'utf8')):null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
693
693
  }
694
694
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
695
695
  const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
package/lib/check.mjs CHANGED
@@ -449,8 +449,16 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
449
449
  needs.push(...await proveAppNeeds({plan:{...plan,needs:[need]},surfaces,shots,report,ready:appReady,root,restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)),headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null,sessionFor})}));
450
450
  }
451
451
  if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
452
- const ok = !needs || needs.every(n => n.status === 'met');
453
- return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
452
+ const outcome = appNeedsOutcome(needs);
453
+ return { ...outcome, ...(needs ? { needs } : {}), command: 'check', kind: 'app', webglFailure, isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
454
454
  } catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
455
455
  finally { await ownerClient?.close().catch(()=>{}); await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
456
456
  }
457
+
458
+ /** Pending is unfinished proof, not a failure of the baseline checks. */
459
+ export function appNeedsOutcome(needs) {
460
+ const failed=needs?.some(n=>!['met','pending'].includes(n.status));
461
+ if(failed)return {ok:false,exitCode:1,why:'Some needs from the person’s Codex are not yet met; see needs and captures'};
462
+ if(needs?.some(n=>n.status==='pending'))return {ok:false,pending:true,exitCode:0,why:'Pending needs are not yet proved.'};
463
+ return {ok:true,exitCode:0};
464
+ }
package/lib/dev.mjs CHANGED
@@ -200,6 +200,7 @@ export function devConfig(root, remoteAi) {
200
200
  // Always retain the exact local binding configuration for CLI commands after a deploy.
201
201
  const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
202
202
  delete json.$schema;
203
+ json.vars={...json.vars,HOMIE_DEV:'1'};
203
204
  json.main = entry ?? abs(json.main);
204
205
  if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
205
206
  json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
@@ -351,6 +352,12 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
351
352
  m.on('close', done);
352
353
  m.on('error', done);
353
354
  });
355
+ const {functionDeploymentSQL}=await import('./functions-build.mjs');
356
+ const registerFunctions=()=>new Promise(resolve=>{
357
+ const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
358
+ child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
359
+ });
360
+ if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
354
361
  const origin = `http://127.0.0.1:${port}`;
355
362
  const printedOrigin = `http://localhost:${port}`;
356
363
  log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
@@ -480,7 +487,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
480
487
  if (rebuilding || ending) return;
481
488
  const stamp = toolsStamp(); if (stamp === lastTools) return;
482
489
  lastTools = stamp; rebuilding = true;
483
- try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
490
+ try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); if(!await registerFunctions())throw new Error('Local function registration failed'); }
484
491
  catch (error) { log(`Studio tools did not build: ${error.message}`); }
485
492
  finally { rebuilding = false; }
486
493
  };
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
1
2
  import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
2
3
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
3
4
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
@@ -829,6 +830,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
829
830
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
830
831
  [`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
831
832
  [`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
833
+ [`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
834
+ [`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
835
+ [`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
832
836
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
833
837
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
834
838
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1076,8 +1080,23 @@ function ensureBookingResources(root) {
1076
1080
  if(existsSync(file))return null;
1077
1081
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
1078
1082
  }
1083
+ function ensureToolNamespaces(root) {
1084
+ const file=join(root,'site','migrations',TOOL_NAMESPACE_MIGRATION_FILE);
1085
+ if(existsSync(file))return null;
1086
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_NAMESPACE_MIGRATION);return `site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`;
1087
+ }
1088
+ function ensureCache(root) {
1089
+ const file=join(root,'site','migrations',CACHE_MIGRATION_FILE);
1090
+ if(existsSync(file))return null;
1091
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,CACHE_MIGRATION);return `site/migrations/${CACHE_MIGRATION_FILE}`;
1092
+ }
1093
+ function ensureOperational(root) {
1094
+ const file=join(root,'site','migrations',OPERATIONAL_MIGRATION_FILE);
1095
+ if(existsSync(file))return null;
1096
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
1097
+ }
1079
1098
  export function ensureMigrations(root) {
1080
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root)].filter(Boolean);
1099
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root)].filter(Boolean);
1081
1100
  }
1082
1101
 
1083
1102
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
@@ -1,3 +1,6 @@
1
+ import {mkdirSync,writeFileSync} from 'node:fs';
2
+ import {join} from 'node:path';
3
+ import {redactText} from './redact.mjs';
1
4
  /** Reconcile through the owner's official Stripe CLI session; never export its credentials. */
2
5
  import { createHash, randomUUID } from 'node:crypto';
3
6
  import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
@@ -85,7 +88,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
85
88
  catalogPrices.set(item.id, price.id);
86
89
  if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
87
90
  if (item.price === 0) return; // Free orders never leave the studio.
88
- const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' && !['supporter','deposit'].includes(item.kind) ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
91
+ const params = { ...customText, line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' && !['supporter','deposit'].includes(item.kind) ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: item.maxQuantity??999999 } } : {}) }],
89
92
  after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
90
93
  ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
91
94
  ...(item.interval ? { subscription_data: { metadata } } : { payment_intent_data: { metadata } }),
@@ -97,7 +100,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
97
100
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
98
101
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
99
102
  keepLinks.add(link.id);
100
- config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, revision: linkRevision });
103
+ config.items.push({ item: item.id, id: link.id, url: link.url, price: price.id, maxQuantity: shop.policy.repeatPurchases?(item.maxQuantity??999999):1, revision: linkRevision });
101
104
  }));
102
105
  // Menus use paid invoice snapshots: Checkout events omit their line items.
103
106
  // Stripe allows 20 Payment Link lines; invoice snapshots include only the first
@@ -111,7 +114,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
111
114
  const groupOffset=offset;tasks.push(async()=>{
112
115
  const items = group.slice(groupOffset, groupOffset + 10);
113
116
  if (items.length < 2) return;
114
- const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
117
+ const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?(i.maxQuantity??99):1}})),
115
118
  invoice_creation:{enabled:true,invoice_data:{metadata}},
116
119
  after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}},
117
120
  automatic_tax:{enabled:shop.automaticTax}, payment_intent_data:{metadata} };
@@ -121,7 +124,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
121
124
  await verifyLink(link,params);
122
125
  if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
123
126
  keepLinks.add(link.id);
124
- config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
127
+ config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id),maxQuantity:shop.policy.repeatPurchases?(i.maxQuantity??99):1}))});
125
128
  });}
126
129
  // One aggregate payment represents a frozen local basket, including one-item baskets.
127
130
  // Runtime uses prefilled_amount, never a shared Price mutation: concurrent
@@ -152,7 +155,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
152
155
  await verifyLink(link,params);
153
156
  if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
154
157
  keepLinks.add(link.id);
155
- config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
158
+ config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id),maxQuantity:shop.policy.repeatPurchases?(i.maxQuantity??99):1}))});
156
159
  });}
157
160
  config.slots=[];
158
161
  const pause=async link=>{
@@ -174,6 +177,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
174
177
  const identity=hash({slug,site,app:slot.app,collection:slot.collection,slots:slot.slots,slot:slot.slot});
175
178
  let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
176
179
  if(!link && (!slot.capacity||slot.active===false))return;
180
+ if(link && !link.line_items)link=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
177
181
  if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
178
182
  const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
179
183
  const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
@@ -183,14 +187,15 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
183
187
  // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
184
188
  const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
185
189
  const active=slot.active!==false && limit>count;
186
- const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
190
+ const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1,adjustable_quantity:{enabled:true,minimum:1,maximum:Math.max(1,Math.min(999999,slot.capacity-(slot.confirmed??count)))}}],payment_method_types:['card'],
187
191
  restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
188
- if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id)}},`slot-${identity}`);
192
+ if(!link)link=await api('POST','/v1/payment_links',{...params,...(scoped?{expand:['line_items']}:{}),metadata:{...metadata,slot:identity,revision:identity,price:catalogPrices.get(item.id),...(slot.confirmed!==undefined?{seats_revision:seatsRevision,seats_limit:String(limit)}:{})}},`slot-${identity}`);
189
193
  else {
190
194
  // Pause first whenever publishing a changed budget, so old sessions cannot
191
195
  // spend a previous capacity after staff moves or removes a slot.
192
- if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
193
- if (link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
196
+ const groupChanged=!matchesFields((link.line_items?.data??link.line_items)?.[0]?.adjustable_quantity,params.line_items[0].adjustable_quantity);
197
+ if(!active || groupChanged || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
198
+ if (groupChanged || link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,...(groupChanged?{line_items:[{id:(link.line_items?.data??link.line_items)?.[0]?.id,quantity:1,adjustable_quantity:params.line_items[0].adjustable_quantity}]}:{}),active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
194
199
  }
195
200
  await verifyLink(link,params);
196
201
  keepLinks.add(link.id);
@@ -234,7 +239,7 @@ export async function syncPaymentLinks(options) {
234
239
  return all;
235
240
  };
236
241
  const secrets=await w(['secret','list']);
237
- if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
242
+ if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(secrets)};
238
243
  const names=JSON.parse(secrets.stdout??secrets.out);
239
244
  const has=name=>names.some(s=>s.name===name);
240
245
 
@@ -258,19 +263,32 @@ export async function syncPaymentLinks(options) {
258
263
  secret = endpoint.secret;
259
264
  }
260
265
  const fingerprint = hash({config, purchases:purchases?.config});
261
- const unchanged = current && !secret && receipt?.fingerprint === fingerprint && (options.catalog || has('STRIPE_SHOP_LINKS')) && !has('STRIPE_KEY');
266
+ const unchanged = current && !secret && receipt?.fingerprint === fingerprint && Boolean(root || options.catalog) && !has('STRIPE_KEY');
262
267
  if (!unchanged) {
263
- const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_CHECKOUT_MODE: 'links', ...(options.catalog ? {} : {STRIPE_SHOP_LINKS: JSON.stringify(config)}), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
264
- if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
268
+ const secrets={...purchaseSecrets,...(secret?{STRIPE_WEBHOOK_SECRET:secret}:{})};
269
+ const saved=Object.keys(secrets).length?await w(['secret','bulk'],{input:JSON.stringify(secrets)}):{code:0};
270
+ if(saved.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(saved)};
265
271
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
266
272
  if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
267
273
  saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
268
274
  }
269
- await purchases?.retire();
270
- // Retire only after the replacement configuration and signing secret are installed.
271
- if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
275
+ if(root && !options.catalog) {
276
+ const bookingSlots=options.bookingSlots??[];
277
+ mkdirSync(join(root,'.studio'),{recursive:true});
278
+ writeFileSync(join(root,'.studio','stripe-mcp.json'),JSON.stringify({options:{mode,slots:bookingSlots,url:site},source:hash({shop,slots:bookingSlots}),sourceConfig:{shop,slots:bookingSlots},result:{verified:true,verifiedAt:new Date().toISOString(),local:false,site,mode,config}},null,2));
279
+ writeFileSync(join(root,'.studio','stripe-purchase-links.json'),JSON.stringify(purchases?.config??{offers:[]}));
280
+ const {refreshWorkerConfig}=await import('./worker-config.mjs');refreshWorkerConfig(root);
281
+ }
282
+ if(has('STRIPE_CHECKOUT_MODE')) {
283
+ const removed=await w(['secret','delete','STRIPE_CHECKOUT_MODE'],{input:'y\n'});
284
+ if(removed.code!==0)return {ok:false,needs:'cloudflare',why:wranglerError(removed)};
285
+ }
286
+ // Public assets take effect on deploy. Keep old links live until that deployment;
287
+ // the planner returns retirement operations for the subsequent reconciliation.
272
288
  for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
273
- return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
274
- message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
275
- next: ['Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
289
+ return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false, retireAfterDeploy:retire,
290
+ message: 'Your shop uses Stripe Payment Links. Public configuration is saved for the next deploy; no Stripe API key is stored in the Worker.',
291
+ next: ['Deploy the public link configuration before retiring replaced Payment Links.', 'Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
276
292
  }
293
+
294
+ function wranglerError(result) {return 'Cloudflare: '+redactText([result.stderr,result.out,result.stdout].filter(Boolean).join('\n')||'Wrangler failed').text.slice(-4000);}
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
  }
@@ -3963,6 +3963,48 @@
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
+ }
3966
4008
  }
3967
4009
  }
3968
4010
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.53.0",
3
+ "version": "0.54.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.
@@ -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;