@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/CHANGELOG.md CHANGED
@@ -17,6 +17,63 @@ 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.55.0] - 2026-10-15
21
+
22
+ **Plugin 0.55.0**
23
+
24
+ Studios start faster and serve files straight from Cloudflare, background work and Stripe events go through retrying queues so nothing is lost, and the runtime follows Cloudflare's published practice.
25
+
26
+ ### Changed
27
+
28
+ - Deploy with D1 primary fallback when replication is unavailable; only install needed schedules, explain account cron limits, and preserve retired-source, HEAD and concept-page protections.
29
+ - Deliver mutations and verified Stripe webhooks through retryable Queues. Store PaymentIntent ids before follow-up, recover lost responses using the original idempotency key, and retain uncertain payments for review.
30
+ - Batch cold metadata, use causally consistent D1 sessions, bake catalogues, route public assets first, isolate subscription objects and load rendering modules at their entry points. Index MCP expiry and deliver catalogue notifications in background work.
31
+ - Split request routing, runtime helpers, Table, Lobby, room chat, controls and wire validation into modules without changing behavior.
32
+ - Skip unchanged analytics counters and watermarks, paginate rollups by UTC day, and show delayed counts in owner statistics.
33
+ - Set `stats.analytics: true` in studio.json to count page activity with Analytics Engine and hourly, replay-safe D1 rollups that preserve historical totals. Owner statistics explain ingestion lag and sampling.
34
+
35
+ ### Fixed
36
+
37
+ - Weight Analytics Engine page counts by the sample interval, preserving historical totals and retry-safe corrections under sampling.
38
+ - Stop queued Stripe reconciliation database operations and provider calls after lease renewal fails or the lease expires, leaving the event pending for retry.
39
+ - Preserve live MCP OAuth and webhook dedupe rows during cleanup by using Unix seconds throughout MCP storage; reclaim idle guests occasionally on guest creation even without a studio cron.
40
+ - Recover verified Stripe intake from D1 after queue failures, renew reconciliation leases, preserve replay API versions, report stuck deliveries, and isolate scheduled maintenance failures. Resume partial queue setup with clear CI token permission guidance.
41
+ - Confine Chrome debugging pipes to CI with protocol deadlines matching launch deadlines; preserve owner CLI flags and capture child stderr, exits, shared-memory availability and concurrent Chrome processes in CI artifacts.
42
+
43
+ ### Upgrade notes
44
+
45
+ - Existing restricted Stripe keys need Products and Prices Read for expanded checkout/subscription lines. Shop-only keys also need Subscriptions Read, Invoices Read and Customer portal Write for renewal verification and subscription management. Update both test and live keys in Stripe before deploying. Expanded objects require their own permissions: https://docs.stripe.com/stripe-apps/reference/permissions.
46
+
47
+ ## [0.54.0] - 2026-10-15
48
+
49
+ **Plugin 0.54.0**
50
+
51
+ 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.
52
+
53
+ ### Changed
54
+
55
+ - Reduce pull-request CI work through shared studio fixtures, focused compatibility coverage, and nightly release checks.
56
+
57
+ ### Fixed
58
+
59
+ - **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.
60
+ - **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.
61
+ - **Test assets:** require a valid test session for the same app, role and scope whenever a test-kind asset ticket is used.
62
+
63
+ - **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.
64
+ - **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.
65
+ - **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.
66
+ - **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.
67
+ - **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.
68
+ - **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.
69
+ - **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.
70
+ - **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.
71
+ - **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.
72
+
73
+ ### Upgrade notes
74
+
75
+ - 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.
76
+
20
77
  ## [0.53.0] - 2026-10-15
21
78
 
22
79
  **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=[];});
@@ -0,0 +1,31 @@
1
+ import {readStudio,writeStudio,workerDir} from './studio.mjs';
2
+ import {cloudflareAuth} from './brain-probe.mjs';
3
+ import {join} from 'node:path';
4
+ export const backgroundQueues=worker=>({work:worker.slice(0,48)+'-background',dead:worker.slice(0,48)+'-background-dlq'});
5
+ /** Names reserve this studio's queues. An interrupted pre-deploy creation has no
6
+ * bindings yet; inspect structured API records before resuming that reservation. */
7
+ export async function ensureBackgroundQueues(root,run,{accountId,fetchFn=fetch,headers}={}) {
8
+ const studio=readStudio(root),names=backgroundQueues(studio.cloudflare.worker);
9
+ accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare.accountId;
10
+ for(const name of [names.dead,names.work]) {
11
+ if(readStudio(root).cloudflare.created?.includes('queue:'+name))continue;
12
+ const result=run(['queues','create',name]);
13
+ if(result.code!==0) {
14
+ headers??=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
15
+ const permission='The deployment token needs Account > Queues > Edit on this account. Add that permission to the CI/Workers Builds token (or sign in again with Wrangler), then retry deploy.';
16
+ if(!headers||!accountId)throw new Error(permission);
17
+ const response=await fetchFn(`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/queues?name=${encodeURIComponent(name)}`,{headers,signal:AbortSignal.timeout(15000)});
18
+ if(response.status===401||response.status===403)throw new Error(permission);
19
+ const body=await response.json();
20
+ if(!response.ok||body.success===false)throw new Error(`Could not inspect queue ${name} (HTTP ${response.status}). ${permission}`);
21
+ const queue=body.result?.find(q=>q.queue_name===name);
22
+ if(!queue)throw new Error(`Could not create queue ${name}. ${permission} Wrangler: ${result.out?.trim().split('\n').slice(-4).join(' ')}`);
23
+ const producers=queue.producers??[],consumers=queue.consumers??[];
24
+ if((queue.producers_total_count??producers.length)>producers.length||(queue.consumers_total_count??consumers.length)>consumers.length||
25
+ producers.some(p=>p.type!=='worker'||p.script!==studio.cloudflare.worker)||consumers.some(c=>c.type!=='worker'||(c.script_name??c.script)!==studio.cloudflare.worker))
26
+ throw new Error(`Queue ${name} is bound to another producer or consumer; it must belong exclusively to this studio`);
27
+ }
28
+ const latest=readStudio(root);
29
+ writeStudio(root,{...latest,cloudflare:{...latest.cloudflare,created:[...new Set([...(latest.cloudflare.created??[]),'queue:'+name])].sort()}});
30
+ }
31
+ }
package/lib/build.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {staticHeaders} from './static-assets.mjs';
1
2
  import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
2
3
  import { directoryUrl } from '../worker/directory-choice.mjs';
3
4
  import { domainOrigin } from './studio.mjs';
@@ -689,7 +690,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
689
690
  mkdirSync(join(dist, '_site'), { recursive: true });
690
691
  if(publicMcpSetup(root)) {
691
692
  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.'}));
693
+ 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
694
  }
694
695
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
695
696
  const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
@@ -741,6 +742,8 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
741
742
  const image = await playerImage(catalogue, game, dist, log);
742
743
  if (image) game.playerImage = image;
743
744
  }
745
+ const headerPath = join(dist, '_headers');
746
+ writeFileSync(headerPath, staticHeaders(existsSync(headerPath) ? readFileSync(headerPath, 'utf8') : '', {concept: studio.concept === true}));
744
747
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
745
748
  // What this build made, for whatever runs next (a deploy says which games it changes; a script checks the live
746
749
  // site against it): per game its digest, its bundle and chunks, and whether it differs from the build before.
@@ -761,7 +764,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
761
764
  await beforePublish();
762
765
  const hosted = (await rulesBuild()).writeRules(root, ruled, all.filter((g) => rulesOf.get(g.id)?.host === 'server').map((g) => g.id));
763
766
  log('Building site Worker…');
764
- await (await import('./worker-build.mjs')).buildWorker(root, esbuild);
767
+ await (await import('./worker-build.mjs')).buildWorker(root, esbuild, { catalogue, posts: posts.map(p => ({ ...p })) });
765
768
  // All of it is there: now, and only now, it becomes site/dist (lib/stage.mjs).
766
769
  const swapped = swapIn(dist, live);
767
770
  return {
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
+ }
@@ -1,3 +1,5 @@
1
+ import {enableReadReplication} from './read-replication.mjs';
2
+ import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
1
3
  import { directoryUrl } from '../worker/directory-choice.mjs';
2
4
  import { roomLayout } from '../worker/gate.mjs';
3
5
  import {workerEntry} from './worker-build.mjs';
@@ -101,6 +103,12 @@ function parseJson(text) {
101
103
  export function explainCloudflare(out, accountId = null) {
102
104
  const text = String(out ?? '');
103
105
  const dash = accountId ? `https://dash.cloudflare.com/${accountId}` : 'https://dash.cloudflare.com';
106
+ if (/queue.*(?:permission|authentication|forbidden|unauthori)|(?:permission|authentication|forbidden|unauthori).*queue/is.test(text)) {
107
+ return {needs:'queues-permission',why:'The deployment token needs Account > Queues > Edit on this account. Update the CI/Workers Builds token permissions, then retry deploy.'};
108
+ }
109
+ if (/(?:cron|schedule).*?(?:limit|maximum|exceed)|(?:limit|maximum|exceed).*?(?:cron|schedule)/i.test(text)) {
110
+ return { needs: 'cron-limit', why: 'This account has reached its Cron Trigger limit (5 on Workers Free). Remove unused triggers from other Workers in the Cloudflare dashboard, or upgrade the account plan, then retry deploy. Required studio schedules have been retained.' };
111
+ }
104
112
  if (/\b10034\b|verify your email/i.test(text)) {
105
113
  return { needs: 'cloudflare-verify-email', why: `Cloudflare needs this account's email address verified before it runs a Worker. Ask the person to open the verification email from Cloudflare (or ${dash}/profile and "Send verification email"), click it. The AI resumes deployment after verification.` };
106
114
  }
@@ -210,6 +218,9 @@ export function deployPlan(root) {
210
218
  { kind: 'Durable Object', name: 'Table', what: hosted.length ? `one per public room: the netplay relay (seats, snapshots), and for ${hosted.map((h) => h.id).join(', ')} the game's own rules, which run here and nowhere else` : 'one per public room: the netplay relay (seats, host, snapshots); runs no game code', state: 'declared by the Worker' },
211
219
  ...hosted.map((h) => ({ kind: 'Game rules', name: h.id, what: `One full ${h.seats}-player room activates ${h.objects} objects (${h.layout.gates > 1 ? `${h.layout.gates} Gates, the room and its diagnostics` : 'the room'}): approximately ${h.gbSeconds} GB-seconds/hour before Lobby activity. Player input/ping alone contributes ${h.requests} request units/hour; relay frames and receipts, HTTP and storage are additional. Occupancy, batching and account usage determine the bill.`, state: 'in the Worker' })),
212
220
  { kind: 'Durable Object', name: 'Lobby', what: 'one per game: puts strangers who press Play into the same room', state: 'declared by the Worker' },
221
+ ...['Gate','Concentrator','AppRecordHub','EventHub'].map(name=>({kind:'Durable Object',name,what:name==='Gate'?'room transport and diagnostics':name==='Concentrator'?'compatibility for existing studios':'independent live subscriptions',state:'declared by the Worker'})),
222
+ ...Object.values(backgroundQueues(cf.worker)).map(name=>({kind:'Queue',name,what:'durable background delivery with retries and a dead-letter queue',state:mark('queue:'+name)})),
223
+ ...(studio.stats?.analytics===true ? [{kind:'Analytics Engine',name:`${cf.worker.replace(/-/g,'_')}_visits`,what:'page counts, rolled into historical D1 totals hourly',state:'bound by the Worker'}] : []),
213
224
  storage
214
225
  ? { kind: 'R2 bucket', name: storage, what: 'the studio\'s big media: songs and videos over the size studio.json media.r2Over names (1 MiB unless set) or that git leaves out, uploaded and checked by SHA-256 before the site stops carrying them, served at their same addresses', state: 'exists (this studio made it)', plan: 'R2 (payment method on the account; 10 GB-month free)' }
215
226
  : { kind: 'R2 bucket', name: null, what: 'none: a new studio needs no storage. `homie-studio storage add` adds it later, for large media only', state: 'not created', plan: null },
@@ -384,8 +395,10 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
384
395
  step(`Cron triggers: ${(readConfig(root)?.triggers?.crons??[]).join("; ")||"none (remove toolkit triggers)"}`);
385
396
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
386
397
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
398
+ try {await ensureBackgroundQueues(root,w,{accountId,...(fetchFn?{fetchFn}:{})});for(const key of readStudio(root).cloudflare.created??[])created.add(key);} catch(error) {return refuse(error.message);}
387
399
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
388
400
  if (migrate.code !== 0) return refuse(`D1 migrations failed: ${migrate.out.trim().split('\n').slice(-4).join(' ')}`, migrate.out);
401
+ try {const replication=await enableReadReplication(root,w,{accountId,databaseId:db.uuid,...(fetchFn?{fetchFn}:{})});step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
389
402
  step('D1 migrations applied');
390
403
  const subscriptions=w(['d1','execute',cf.d1,'--remote','--command',(await import('./functions-build.mjs')).functionDeploymentSQL(root)]);
391
404
  if(subscriptions.code!==0)return refuse('Could not register studio functions',subscriptions.out);
@@ -473,7 +486,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
473
486
 
474
487
  // studio.json keeps what is safe to commit: names, ids, the custom domain; never the workers.dev address.
475
488
  const { url: _url, deployedAt: _at, ...kept } = cf;
476
- const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...created].sort() };
489
+ const next = { ...kept, ...(cf.url && !isWorkersDev(cf.url) ? { url: cf.url } : {}), accountId, d1Id: db.uuid, r2: cf.r2 ?? null, created: [...new Set([...created,...(readStudio(root).cloudflare.created??[])])].sort() };
477
490
  writeStudio(root, { ...readStudio(root), cloudflare: next });
478
491
  return {
479
492
  ok: true, command: 'deploy', url, mcp: url ? `${url}/mcp` : null, connectAI: url ? `${url}/_studio/office/connections` : null, ...(workersDev ? { workersDev, local: LOCAL_STATE } : {}), worker: cf.worker, d1: cf.d1, r2, account: accountId, announced, steps,
@@ -604,6 +617,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
604
617
  const refuse = (why, out) => ({ ok: false, command: 'deploy', ci: true, ...(explainCloudflare(out) ?? { why }), steps });
605
618
  if (!existsSync(join(root, 'site', 'dist', 'games.json'))) return { ok: false, command: 'deploy', ci: true, why: 'nothing is built: the build command is `npm run build` (homie-studio build), and it runs before this', steps };
606
619
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
620
+ try {await ensureBackgroundQueues(root,w);} catch(error) {return refuse(error.message);}
607
621
  const apply = () => w(['d1', 'migrations', 'apply', names.binding, '--remote']);
608
622
  let migrate = apply();
609
623
  const first = migrate.code !== 0 && /not found|could(?:n't| not) find|does not exist|no database|database_id/i.test(migrate.out);
@@ -633,6 +647,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
633
647
  step('D1 migrations applied (the database was created with this first deploy)');
634
648
  const registered=register();if(registered.code!==0)return refuse('Could not register studio functions',registered.out);
635
649
  }
650
+ try {const replication=await enableReadReplication(root,w);step(replication.enabled?'D1 read replication enabled':`D1 uses the primary: ${replication.reason}`);} catch(error) {step(`D1 uses the primary: ${error.message}`);}
636
651
  // The live address, from what Wrangler printed (a custom domain in studio.json wins).
637
652
  const workersDev = /https:\/\/[a-z0-9.-]+\.workers\.dev/i.exec(dep.out)?.[0] ?? null;
638
653
  let url = null;
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
  };
@@ -0,0 +1,26 @@
1
+ import {cloudflareAuth} from './brain-probe.mjs';
2
+ import {readConfig} from './routes.mjs';
3
+ import {readStudio,workerDir} from './studio.mjs';
4
+ import {join} from 'node:path';
5
+ /** Enable replicas on this studio's database using the same login as deployment.
6
+ * A missing account/id (first Workers Builds provisioning) is retried next deploy.
7
+ * Auth failures are reported; the primary-first runtime remains consistent.
8
+ */
9
+ export async function enableReadReplication(root, run, {accountId, databaseId, fetchFn=fetch}={}) {
10
+ const studio=readStudio(root);
11
+ accountId??=process.env.CLOUDFLARE_ACCOUNT_ID??studio.cloudflare?.accountId;
12
+ databaseId??=readConfig(root)?.d1_databases?.find(db=>db.binding==='DB')?.database_id;
13
+ if (!accountId || !databaseId) return {enabled:false,reason:'database provisioning has not supplied its account and id'};
14
+ if (process.env.HOMIE_STUDIO_WARM==='0') return {enabled:false,reason:'offline test'};
15
+ try {
16
+ const headers=await cloudflareAuth({bin:join(root,'node_modules/.bin/wrangler'),cwd:workerDir(root),exec:async(_bin,args)=>{const r=run(args);return {code:r.code,stdout:r.stdout??r.out};}});
17
+ if (!headers) return {enabled:false,reason:'Cloudflare authentication unavailable'};
18
+ const url=`https://api.cloudflare.com/client/v4/accounts/${encodeURIComponent(accountId)}/d1/database/${encodeURIComponent(databaseId)}`;
19
+ const response=await fetchFn(url,{method:'PATCH',headers:{...headers,'content-type':'application/json'},body:JSON.stringify({read_replication:{mode:'auto'}}),signal:AbortSignal.timeout(15000)});
20
+ const body=await response.json();
21
+ if(!response.ok||body.success===false||body.result?.read_replication?.mode!=='auto')throw new Error(`Could not enable D1 read replication (HTTP ${response.status}); the deployment login needs D1 Edit`);
22
+ return {enabled:true};
23
+ } catch (error) {
24
+ return {enabled:false,reason:`D1 uses the primary; read replication could not be enabled: ${error.message}`};
25
+ }
26
+ }
package/lib/scaffold.mjs CHANGED
@@ -1,3 +1,7 @@
1
+ import {ANALYTICS_MIGRATION,ANALYTICS_MIGRATION_FILE} from '../worker/analytics.mjs';
2
+ import {backgroundQueues} from './background-queue.mjs';
3
+ import {WORKER_ROUTES} from './static-assets.mjs';
4
+ import {EXTRA_RUNTIME_MIGRATIONS,RUNTIME_INDEX_MIGRATION,RUNTIME_INDEX_MIGRATION_FILE,TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
1
5
  import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
2
6
  import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
3
7
  import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
@@ -75,10 +79,10 @@ export function slugify(name) {
75
79
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
76
80
  * the binding names and keep it linked.
77
81
  */
78
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
82
+ export function wranglerConfig({analytics = false, workerRoutes = WORKER_ROUTES, worker, name, d1, d1Id = null, r2 = null, layout = 'root', main = null, ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null, eventFeeds = null }) {
79
83
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/runtime/worker.js', dist: './dist', migrations: 'migrations' }
80
84
  : { schema: 'node_modules', main: 'site/src/runtime/worker.js', dist: './site/dist', migrations: 'site/migrations' };
81
- const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }];
85
+ const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }, { name: 'GATE', class_name: 'Gate' }, { name: 'CONCENTRATOR', class_name: 'Concentrator' }, {name:'APP_RECORDS',class_name:'AppRecordHub'}, {name:'EVENTS',class_name:'EventHub'}];
82
86
  const config = {
83
87
  $schema: `${at.schema}/wrangler/config-schema.json`,
84
88
  name: worker,
@@ -91,25 +95,27 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
91
95
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
92
96
  no_bundle: true, find_additional_modules: true, rules: [{ type: 'ESModule', globs: ['**/*.js'], fallthrough: true }],
93
97
  compatibility_flags: ['nodejs_compat', 'global_fetch_strictly_public', 'disallow_eval_during_startup'],
94
- ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
98
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, ...(partsRateLimit ? { ratelimits: [partsRateLimit] } : {}) } : {}),
95
99
  workers_dev: true,
96
100
  preview_urls: true,
97
101
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
98
102
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
99
103
  // (lib/routes.mjs). A studio without routes gets no key at all.
100
104
  ...(routes?.length ? { routes } : {}),
101
- ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
102
- assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
105
+ triggers: { crons: [...new Set([...(triggers?.crons ?? []), ...(paidParts ? ['*/5 * * * *','17 * * * *'] : []), ...(analytics ? ['17 * * * *'] : [])])] },
106
+ assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: workerRoutes },
107
+ ...(analytics ? {analytics_engine_datasets:[{binding:'STUDIO_ANALYTICS',dataset:`${worker.replace(/-/g,'_')}_visits`}],analytics:{binding:'ANALYTICS_SQL'}} : {}),
108
+ queues:{producers:[{binding:'BACKGROUND',queue:backgroundQueues(worker).work},{binding:'BACKGROUND_DLQ',queue:backgroundQueues(worker).dead}],consumers:[{queue:backgroundQueues(worker).work,max_batch_size:100,max_batch_timeout:1,max_retries:5,dead_letter_queue:backgroundQueues(worker).dead}]},
103
109
  durable_objects: { bindings: rooms },
104
- migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }],
110
+ migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }, { tag: 'v2-gates', new_sqlite_classes: ['Gate', 'Concentrator'] }, {tag:'v3-streams',new_sqlite_classes:['AppRecordHub','EventHub']}],
105
111
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
106
112
  ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
107
113
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
108
114
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
109
115
  ...(ai ? { ai: { binding: 'AI' } } : {}),
110
- vars: { STUDIO_NAME: name, ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
116
+ vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), ...(eventFeeds&&Object.keys(eventFeeds).length?{STUDIO_EVENT_FEEDS:JSON.stringify(eventFeeds)}:{}) },
111
117
  observability: { enabled: true },
112
- previews: { vars: { STUDIO_NAME: name, HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
118
+ previews: { vars: { STUDIO_NAME: name, ...(analytics ? {STUDIO_ANALYTICS_DATASET: `${worker.replace(/-/g,'_')}_visits`} : {}), HOMIE_PREVIEW: '1' }, durable_objects: { bindings: rooms } },
113
119
  };
114
120
  return `// This studio's site on its own Cloudflare account (written by homie-studio). Everything here runs on
115
121
  // the studio's Cloudflare Workers account. \`npm run deploy\` (on this computer, or in Workers Builds) puts it live;
@@ -829,6 +835,12 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
829
835
  [`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
830
836
  [`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
831
837
  [`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
838
+ [`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
839
+ [`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
840
+ [`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
841
+ [`site/migrations/${ANALYTICS_MIGRATION_FILE}`]: ANALYTICS_MIGRATION,
842
+ [`site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`]: RUNTIME_INDEX_MIGRATION,
843
+ ...Object.fromEntries(Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>[`site/migrations/${name}`,sql])),
832
844
  [`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
833
845
  [`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
834
846
  [`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
@@ -1076,8 +1088,33 @@ function ensureBookingResources(root) {
1076
1088
  if(existsSync(file))return null;
1077
1089
  mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
1078
1090
  }
1091
+ function ensureToolNamespaces(root) {
1092
+ const file=join(root,'site','migrations',TOOL_NAMESPACE_MIGRATION_FILE);
1093
+ if(existsSync(file))return null;
1094
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_NAMESPACE_MIGRATION);return `site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`;
1095
+ }
1096
+ function ensureCache(root) {
1097
+ const file=join(root,'site','migrations',CACHE_MIGRATION_FILE);
1098
+ if(existsSync(file))return null;
1099
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,CACHE_MIGRATION);return `site/migrations/${CACHE_MIGRATION_FILE}`;
1100
+ }
1101
+ function ensureOperational(root) {
1102
+ const file=join(root,'site','migrations',OPERATIONAL_MIGRATION_FILE);
1103
+ if(existsSync(file))return null;
1104
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
1105
+ }
1106
+ function ensureRuntimeIndexes(root) {
1107
+ const file=join(root,'site','migrations',RUNTIME_INDEX_MIGRATION_FILE);
1108
+ if(existsSync(file))return null;
1109
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,RUNTIME_INDEX_MIGRATION);return `site/migrations/${RUNTIME_INDEX_MIGRATION_FILE}`;
1110
+ }
1111
+ function ensureAnalytics(root) {
1112
+ const file=join(root,'site','migrations',ANALYTICS_MIGRATION_FILE);
1113
+ if(existsSync(file))return null;
1114
+ mkdirSync(dirname(file),{recursive:true});writeFileSync(file,ANALYTICS_MIGRATION);return `site/migrations/${ANALYTICS_MIGRATION_FILE}`;
1115
+ }
1079
1116
  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);
1117
+ 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),ensureRuntimeIndexes(root),...Object.entries(EXTRA_RUNTIME_MIGRATIONS).sort(([a],[b])=>a.localeCompare(b)).map(([name,sql])=>{const file=join(root,'site','migrations',name);if(existsSync(file))return null;mkdirSync(dirname(file),{recursive:true});writeFileSync(file,sql);return `site/migrations/${name}`;}),ensureAnalytics(root)].filter(Boolean);
1081
1118
  }
1082
1119
 
1083
1120
  /** 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);}