@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.
- package/CHANGELOG.md +57 -0
- package/bin/homie-studio.mjs +1 -1
- package/guides/app/SKILL.md +2 -2
- package/guides/shop/SKILL.md +6 -0
- package/lib/app-needs.mjs +8 -1
- package/lib/background-queue.mjs +31 -0
- package/lib/build.mjs +5 -2
- package/lib/check.mjs +10 -2
- package/lib/cloudflare.mjs +16 -1
- package/lib/dev.mjs +8 -1
- package/lib/read-replication.mjs +26 -0
- package/lib/scaffold.mjs +46 -9
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/shop.mjs +7 -0
- package/lib/static-assets.mjs +27 -0
- package/lib/template-history.json +84 -0
- package/lib/worker-build.mjs +13 -4
- package/lib/worker-config.mjs +3 -1
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/analytics.mjs +84 -0
- package/worker/app-frame.mjs +1 -1
- package/worker/app-graphics.mjs +1 -1
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +27 -10
- package/worker/app-tools.mjs +1 -0
- package/worker/asset-paths.mjs +2 -0
- package/worker/background-queue.mjs +30 -0
- package/worker/build-id.mjs +1 -1
- package/worker/built-metadata.mjs +2 -0
- package/worker/catalogue.mjs +18 -0
- package/worker/chat-page.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/embed.mjs +1 -1
- package/worker/event-resources.mjs +29 -12
- package/worker/function-registry.mjs +4 -0
- package/worker/functions.mjs +7 -5
- package/worker/gate-routing.mjs +61 -0
- package/worker/gate.mjs +12 -68
- package/worker/hosted-registry.mjs +11 -0
- package/worker/hosted.mjs +3 -17
- package/worker/index.mjs +58 -2772
- package/worker/lazy-objects.mjs +40 -0
- package/worker/lobby-limits.mjs +2 -0
- package/worker/lobby.mjs +251 -0
- package/worker/lounge.mjs +3 -3
- package/worker/mcp-event-streams.mjs +3 -3
- package/worker/mcp-events.mjs +4 -4
- package/worker/mcp-store.mjs +12 -1
- package/worker/mcp.mjs +5 -1
- package/worker/office.mjs +105 -34
- package/worker/operational-schema.mjs +44 -0
- package/worker/page-policy.mjs +29 -0
- package/worker/pages.mjs +15 -29
- package/worker/parts.mjs +2 -1
- package/worker/payment-recovery.mjs +33 -19
- package/worker/player-registry.mjs +9 -0
- package/worker/players.mjs +8 -7
- package/worker/purchase-machine.mjs +5 -3
- package/worker/reconcile-guard.mjs +22 -0
- package/worker/request-db.mjs +78 -0
- package/worker/responses.mjs +18 -0
- package/worker/room-chat.mjs +216 -0
- package/worker/room-controls.mjs +296 -0
- package/worker/room-lifecycle.mjs +10 -0
- package/worker/room-protocol.mjs +197 -0
- package/worker/room.mjs +38 -731
- package/worker/rooms-cache.mjs +19 -0
- package/worker/routes.mjs +667 -0
- package/worker/runtime-support.mjs +445 -0
- package/worker/seats.mjs +1 -1
- package/worker/selling/index.mjs +1 -0
- package/worker/selling/runtime.mjs +7 -5
- package/worker/servers.mjs +5 -4
- package/worker/service-run.mjs +1 -1
- package/worker/shop-links.mjs +2 -2
- package/worker/shop-page.mjs +2 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +89 -27
- package/worker/stats.mjs +7 -6
- package/worker/stream-objects.mjs +18 -0
- package/worker/stripe-intake.mjs +79 -0
- package/worker/stripe.mjs +3 -0
- package/worker/table.mjs +1212 -0
- package/worker/tool-registry.mjs +10 -0
- 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**
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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) {
|
package/guides/app/SKILL.md
CHANGED
|
@@ -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
|
|
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
|
|
package/guides/shop/SKILL.md
CHANGED
|
@@ -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 (
|
|
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
|
|
453
|
-
return {
|
|
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/cloudflare.mjs
CHANGED
|
@@ -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' },
|
|
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
|
-
|
|
102
|
-
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first:
|
|
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). */
|
package/lib/shop-links.mjs
CHANGED
|
@@ -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?
|
|
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
|
-
|
|
193
|
-
if
|
|
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:
|
|
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
|
|
266
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && Boolean(root || options.catalog) && !has('STRIPE_KEY');
|
|
262
267
|
if (!unchanged) {
|
|
263
|
-
const
|
|
264
|
-
|
|
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
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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.
|
|
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);}
|