@homie-rocks/studio 0.53.0 → 0.54.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -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/build.mjs +1 -1
- package/lib/check.mjs +10 -2
- package/lib/dev.mjs +8 -1
- package/lib/scaffold.mjs +20 -1
- package/lib/shop-links.mjs +37 -19
- package/lib/shop-mcp.mjs +7 -1
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/rules/view.ts +1 -1
- package/shop/SHOP.md +13 -9
- package/worker/app-records.mjs +4 -2
- package/worker/app-shell.mjs +9 -4
- package/worker/app-streams.mjs +26 -9
- package/worker/app-tools.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/chat-store.mjs +5 -4
- package/worker/event-resources.mjs +6 -1
- package/worker/index.mjs +42 -25
- package/worker/office.mjs +89 -14
- package/worker/operational-schema.mjs +30 -0
- package/worker/pages.mjs +7 -1
- package/worker/request-db.mjs +53 -0
- package/worker/servers.mjs +5 -4
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-page.mjs +1 -0
- package/worker/shop-rules.mjs +3 -2
- package/worker/shop-store.mjs +4 -3
- package/worker/shop.mjs +62 -7
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,36 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.54.0] - 2026-10-15
|
|
21
|
+
|
|
22
|
+
**Plugin 0.54.0**
|
|
23
|
+
|
|
24
|
+
Studios answer faster, private apps stay private and usable, shops catch every payment, and the toolkit's CI costs a quarter of what it did.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- Reduce pull-request CI work through shared studio fixtures, focused compatibility coverage, and nightly release checks.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **Long-running app screens:** renew role handoffs on records, live and tools responses, reconnect idle live streams before expiry, and set handoffs on records-only pages. Revoked sessions, grants and rotated links still stop access. Remove the shared role secret from the phone address bar and subsequent API URLs.
|
|
33
|
+
- **Tool catalogue:** load and notify once per Worker isolate and database binding, then refresh after studio mutations; static requests no longer repeatedly load tool modules and serialize schemas.
|
|
34
|
+
- **Test assets:** require a valid test session for the same app, role and scope whenever a test-kind asset ticket is used.
|
|
35
|
+
|
|
36
|
+
- **Request path:** remove referral cleanup and function registration from startup; clean retired referral keys in migration 0031. Reuse the deployment catalogue, reuse stable-binding metadata caches guarded by a trigger-maintained revision, avoid repeat tool-catalogue notifications, and dispatch background work after actual writes. Public static assets require no D1 reads; private app assets retain live authorization.
|
|
37
|
+
- **Payment Links:** menu items support `maxQuantity` (default 99), slot links accept groups within published remaining capacity, and confirmation uses the paid seat quantity atomically. Payments that cannot be fulfilled remain visible as review orders with a durable staff event and customer notice. Full connect saves public JSON link assets, retaining replaced links until deployment.
|
|
38
|
+
- **Stripe setup:** observe deterministic product IDs before creating missing search results; keep public checkout mode out of secret uploads and report Wrangler's redacted error text.
|
|
39
|
+
- **Payment review:** single-item links enforce quantity caps at Stripe and fulfillment; old menu snapshots retain their published maximum. Signed payments with missing/unknown references or a currency mismatch remain visible for staff review without granting entitlements.
|
|
40
|
+
- **Background delivery:** include nested background work, preserve D1 sessions and raw-write tracking, and refresh connected AIs’ tool lists on the first ordinary request after deployment.
|
|
41
|
+
- **App checks:** needs without assertions are pending placeholders, rather than failed checks. They remain unproven: check reports not done with a pending label while preserving a nonfailing exit for placeholders.
|
|
42
|
+
- **App navigation:** Big screen uses a five-minute HttpOnly same-origin role session instead of copying the shared access secret; app context URLs include only a validated public room ID.
|
|
43
|
+
- **App subscriptions and privacy:** tool-data revisions and app wakes are scoped to the namespaces each app uses. Private asset capabilities support invite passes, signed-in sessions and tool/test identities, conceal session hashes with encryption plus HMAC, and recheck revocation and grants in one statement.
|
|
44
|
+
- **Prediction:** synchronous `room.me` readers share a presentation even when CPU work crosses a simulation tick; the next microtask refreshes it. Fixes the 60 Hz browser prediction smoke flake without relaxing thresholds.
|
|
45
|
+
|
|
46
|
+
### Upgrade notes
|
|
47
|
+
|
|
48
|
+
- Apply migrations 0031–0033 with the normal studio deploy. Rebuild/deploy after full `shop connect` to publish its public link configuration. Review group availability and resync slot manifests after confirmations, moves or refunds; a Payment Link is not an exclusive unpaid hold.
|
|
49
|
+
|
|
20
50
|
## [0.53.0] - 2026-10-15
|
|
21
51
|
|
|
22
52
|
**Plugin 0.53.0**
|
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=[];});
|
package/lib/build.mjs
CHANGED
|
@@ -689,7 +689,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
689
689
|
mkdirSync(join(dist, '_site'), { recursive: true });
|
|
690
690
|
if(publicMcpSetup(root)) {
|
|
691
691
|
const verified=verifiedMcpConfig(root);
|
|
692
|
-
writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
|
|
692
|
+
writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,purchases:existsSync(join(root,'.studio','stripe-purchase-links.json'))?JSON.parse(readFileSync(join(root,'.studio','stripe-purchase-links.json'),'utf8')):null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
|
|
693
693
|
}
|
|
694
694
|
writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
|
|
695
695
|
const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
|
package/lib/check.mjs
CHANGED
|
@@ -449,8 +449,16 @@ export async function checkApp({ webglFailure = false, isolate = true, root = nu
|
|
|
449
449
|
needs.push(...await proveAppNeeds({plan:{...plan,needs:[need]},surfaces,shots,report,ready:appReady,root,restart:()=>import('./dev-restart.mjs').then(m=>m.requestDevRestart(root,url)),headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null,sessionFor})}));
|
|
450
450
|
}
|
|
451
451
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
452
|
-
const
|
|
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/dev.mjs
CHANGED
|
@@ -200,6 +200,7 @@ export function devConfig(root, remoteAi) {
|
|
|
200
200
|
// Always retain the exact local binding configuration for CLI commands after a deploy.
|
|
201
201
|
const abs = (p) => (p && !p.startsWith('/') ? join(base, p) : p);
|
|
202
202
|
delete json.$schema;
|
|
203
|
+
json.vars={...json.vars,HOMIE_DEV:'1'};
|
|
203
204
|
json.main = entry ?? abs(json.main);
|
|
204
205
|
if (json.assets?.directory) json.assets.directory = abs(json.assets.directory);
|
|
205
206
|
json.d1_databases = (json.d1_databases ?? []).map((d) => ({ ...d, ...(d.migrations_dir ? { migrations_dir: abs(d.migrations_dir) } : {}) }));
|
|
@@ -351,6 +352,12 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
351
352
|
m.on('close', done);
|
|
352
353
|
m.on('error', done);
|
|
353
354
|
});
|
|
355
|
+
const {functionDeploymentSQL}=await import('./functions-build.mjs');
|
|
356
|
+
const registerFunctions=()=>new Promise(resolve=>{
|
|
357
|
+
const child=spawn(bin,['d1','execute','DB','--local','--command',functionDeploymentSQL(root)],{cwd:workerDir(root),env,stdio:['ignore','ignore','inherit']});
|
|
358
|
+
child.on('close',code=>resolve(code===0));child.on('error',()=>resolve(false));
|
|
359
|
+
});
|
|
360
|
+
if(!await registerFunctions())return {ok:false,command:'dev',why:'Local function registration failed; fix the D1 error above before serving requests.'};
|
|
354
361
|
const origin = `http://127.0.0.1:${port}`;
|
|
355
362
|
const printedOrigin = `http://localhost:${port}`;
|
|
356
363
|
log(`Local site: ${printedOrigin}/ (each game: ${printedOrigin}/<id>/play — open it in two browsers)`);
|
|
@@ -480,7 +487,7 @@ export async function dev(root, { lan = false, port: askedPort = 8787, remoteAi
|
|
|
480
487
|
if (rebuilding || ending) return;
|
|
481
488
|
const stamp = toolsStamp(); if (stamp === lastTools) return;
|
|
482
489
|
lastTools = stamp; rebuilding = true;
|
|
483
|
-
try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); }
|
|
490
|
+
try { log('Studio tools changed: type-checking and rebuilding…'); await build(root, { log }); if(!await registerFunctions())throw new Error('Local function registration failed'); }
|
|
484
491
|
catch (error) { log(`Studio tools did not build: ${error.message}`); }
|
|
485
492
|
finally { rebuilding = false; }
|
|
486
493
|
};
|
package/lib/scaffold.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {TOOL_NAMESPACE_MIGRATION,TOOL_NAMESPACE_MIGRATION_FILE,CACHE_MIGRATION,CACHE_MIGRATION_FILE,OPERATIONAL_MIGRATION,OPERATIONAL_MIGRATION_FILE} from '../worker/operational-schema.mjs';
|
|
1
2
|
import {BOOKING_RESOURCES_MIGRATION,BOOKING_RESOURCES_MIGRATION_FILE} from '../worker/booking-resources.mjs';
|
|
2
3
|
import {BOOKING_HOLDS_MIGRATION,BOOKING_HOLDS_MIGRATION_FILE} from '../worker/booking-holds.mjs';
|
|
3
4
|
import {BUSINESS_EVENTS_MIGRATION,BUSINESS_EVENTS_MIGRATION_FILE} from '../worker/business-events.mjs';
|
|
@@ -829,6 +830,9 @@ export { default, Table, Lobby, Gate, Concentrator } from '@homie-rocks/studio/w
|
|
|
829
830
|
[`site/migrations/${APPS_MIGRATION_FILE}`]: APPS_MIGRATION,
|
|
830
831
|
[`site/migrations/${BUSINESS_EVENTS_MIGRATION_FILE}`]: BUSINESS_EVENTS_MIGRATION,
|
|
831
832
|
[`site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`]: BOOKING_RESOURCES_MIGRATION,
|
|
833
|
+
[`site/migrations/${OPERATIONAL_MIGRATION_FILE}`]: OPERATIONAL_MIGRATION,
|
|
834
|
+
[`site/migrations/${CACHE_MIGRATION_FILE}`]: CACHE_MIGRATION,
|
|
835
|
+
[`site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`]: TOOL_NAMESPACE_MIGRATION,
|
|
832
836
|
[`site/migrations/${BOOKING_HOLDS_MIGRATION_FILE}`]: BOOKING_HOLDS_MIGRATION,
|
|
833
837
|
[`site/migrations/${APP_LIVE_MIGRATION_FILE}`]: APP_LIVE_MIGRATION,
|
|
834
838
|
[`site/migrations/${TOOL_DATABASE_MIGRATION_FILE}`]: TOOL_DATABASE_MIGRATION,
|
|
@@ -1076,8 +1080,23 @@ function ensureBookingResources(root) {
|
|
|
1076
1080
|
if(existsSync(file))return null;
|
|
1077
1081
|
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,BOOKING_RESOURCES_MIGRATION);return `site/migrations/${BOOKING_RESOURCES_MIGRATION_FILE}`;
|
|
1078
1082
|
}
|
|
1083
|
+
function ensureToolNamespaces(root) {
|
|
1084
|
+
const file=join(root,'site','migrations',TOOL_NAMESPACE_MIGRATION_FILE);
|
|
1085
|
+
if(existsSync(file))return null;
|
|
1086
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,TOOL_NAMESPACE_MIGRATION);return `site/migrations/${TOOL_NAMESPACE_MIGRATION_FILE}`;
|
|
1087
|
+
}
|
|
1088
|
+
function ensureCache(root) {
|
|
1089
|
+
const file=join(root,'site','migrations',CACHE_MIGRATION_FILE);
|
|
1090
|
+
if(existsSync(file))return null;
|
|
1091
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,CACHE_MIGRATION);return `site/migrations/${CACHE_MIGRATION_FILE}`;
|
|
1092
|
+
}
|
|
1093
|
+
function ensureOperational(root) {
|
|
1094
|
+
const file=join(root,'site','migrations',OPERATIONAL_MIGRATION_FILE);
|
|
1095
|
+
if(existsSync(file))return null;
|
|
1096
|
+
mkdirSync(dirname(file),{recursive:true});writeFileSync(file,OPERATIONAL_MIGRATION);return `site/migrations/${OPERATIONAL_MIGRATION_FILE}`;
|
|
1097
|
+
}
|
|
1079
1098
|
export function ensureMigrations(root) {
|
|
1080
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root)].filter(Boolean);
|
|
1099
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root), ensureMcpMigration(root), ensureFunctionsMigration(root), ensureFunctionCursors(root), ensureFunctionMeta(root), ensureFunctionReference(root), ensureShopRemoveReferrals(root), ensureFunctionRuns(root), ensureToolDatabase(root), ensureBookings(root), ensureSubscriptions(root), ensureAppLive(root), ensureBusinessEvents(root), ensureBookingHolds(root),ensureBookingResources(root),ensureOperational(root),ensureCache(root),ensureToolNamespaces(root)].filter(Boolean);
|
|
1081
1100
|
}
|
|
1082
1101
|
|
|
1083
1102
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
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);}
|
package/lib/shop-mcp.mjs
CHANGED
|
@@ -65,11 +65,17 @@ export async function shopMcpPlan(root, {observations = [], mode = 'test', url,
|
|
|
65
65
|
found={...written,body:{...written.body,...(lines?{line_items:Array.isArray(lines)?{data:lines.map(l=>({...l,price:typeof l.price==='string'?{id:l.price}:l.price}))}:lines}:{})}};
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
|
+
if (!found && method==='POST' && path==='/v1/products' && params.id) {
|
|
69
|
+
const existing=observations.findLast(o=>(o.method??'GET')==='GET' && o.path===`/v1/products/${params.id}`);
|
|
70
|
+
if (!existing) throw {next:{method:'GET',path:`/v1/products/${params.id}`,params:{expand:['default_price']}}};
|
|
71
|
+
if (existing.body?.id===params.id) return existing.body;
|
|
72
|
+
if (existing.body?.error?.code!=='resource_missing') throw new Error('Stripe product lookup failed; resolve the read before creating a product');
|
|
73
|
+
}
|
|
68
74
|
if(!found)throw {next:{method,path,params,...(idempotency?{idempotency}:{})}};
|
|
69
75
|
let body=found.body;
|
|
70
76
|
if(method==='GET' && Array.isArray(body?.data) && !params.starting_after && !params.page && !body.has_more) {
|
|
71
77
|
const kind=path.split('/')[2], rows=new Map(body.data.map(o=>[o.id,o]));
|
|
72
|
-
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST') {
|
|
78
|
+
for(const o of observations.slice(observations.indexOf(found)+1))if(o.method==='POST' || (o.method??'GET')==='GET' && /^\/v1\/products\/[^/]+$/.test(o.path) && o.body?.id) {
|
|
73
79
|
if(o.path.split('/')[2]===kind && o.body?.id)rows.set(o.body.id,o.body);
|
|
74
80
|
if(kind==='prices' && o.body?.default_price?.id)rows.set(o.body.default_price.id,o.body.default_price);
|
|
75
81
|
}
|
|
@@ -3963,6 +3963,48 @@
|
|
|
3963
3963
|
}
|
|
3964
3964
|
}
|
|
3965
3965
|
}
|
|
3966
|
+
},
|
|
3967
|
+
"0.54.0": {
|
|
3968
|
+
"files": {
|
|
3969
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3970
|
+
"README.md": "684b3cca8001673c",
|
|
3971
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3972
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3973
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3974
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3975
|
+
"posts/README.md": "37841aae476bd540",
|
|
3976
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3977
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3978
|
+
},
|
|
3979
|
+
"sections": {
|
|
3980
|
+
"AGENTS.md": {
|
|
3981
|
+
"(intro)": "394cbbe934e80722",
|
|
3982
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3983
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3984
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3985
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3986
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3987
|
+
"## Making games": "bb715272fd1a615b",
|
|
3988
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3989
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3990
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3991
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3992
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3993
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3994
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3995
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3996
|
+
"## Beta": "9d02638207a26e73"
|
|
3997
|
+
}
|
|
3998
|
+
},
|
|
3999
|
+
"named": {
|
|
4000
|
+
"Homie Arcade": {
|
|
4001
|
+
"sections": {
|
|
4002
|
+
"AGENTS.md": {
|
|
4003
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
3966
4008
|
}
|
|
3967
4009
|
}
|
|
3968
4010
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.54.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/rules/view.ts
CHANGED
|
@@ -1314,7 +1314,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
1314
1314
|
get viewTick() { return solo ? solo.viewTick : viewTickNow(); },
|
|
1315
1315
|
get status() { return solo ? 'offline' : status; },
|
|
1316
1316
|
get seat() { return solo ? solo.seat : net.seat; },
|
|
1317
|
-
get me() { pump(); return meNow(true); },
|
|
1317
|
+
get me() { if (!presentedEntity) pump(); return meNow(true); },
|
|
1318
1318
|
each, get,
|
|
1319
1319
|
ray: (from,direction,max,options) => solo ? solo.ray(from,direction,max,options) : viewRay(from,direction,max,options)[0],
|
|
1320
1320
|
rays: options => solo ? solo.rays(options) : raySnapshot(mapAt(latest?.k??0),viewTargets(atTickOf(options)),dims,options,mine?.id),
|
package/shop/SHOP.md
CHANGED
|
@@ -305,7 +305,7 @@ await shop.checkout({ reference: 'table-7' });
|
|
|
305
305
|
The default opens the synced Stripe menu containing those items. The guest chooses
|
|
306
306
|
final quantities **on Stripe**, starting at one for each menu line; the local cart's
|
|
307
307
|
quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
|
|
308
|
-
removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
308
|
+
removing the last item); maximum is the item’s `maxQuantity` from shop.json, default 99 (a positive integer up to 999999). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
|
|
309
309
|
if selected items span menus, the app receives `menu` and should offer separate
|
|
310
310
|
checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
|
|
311
311
|
Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
|
|
@@ -329,8 +329,7 @@ the owner's AI write the complete manifest to `.studio/booking-slots.json` and r
|
|
|
329
329
|
|
|
330
330
|
Sync after publishing or changing slots. Removed or inactive slots deactivate their
|
|
331
331
|
links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
|
|
332
|
-
fixed service and
|
|
333
|
-
count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
332
|
+
fixed service and an adjustable group quantity up to the published remaining seats. Stripe counts completed sessions, not seats: supply the reviewed `confirmed` seat total when resyncing. The database confirms the paid quantity atomically; a capacity race becomes a visible review order rather than an overbooking. Re-sync preserves Stripe's lifetime completed count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
|
|
334
333
|
Refunds do not decrement Stripe's completed-session counter; the owner's AI must
|
|
335
334
|
review availability and resync explicitly. A changed price should use a new slot;
|
|
336
335
|
do not replace a sold link and accidentally reset its capacity counter.
|
|
@@ -441,15 +440,20 @@ stored preset or an empty amount; keep the expected total visible on the phone.
|
|
|
441
440
|
|
|
442
441
|
After a signed paid session matches the frozen link, revision, mode, currency and
|
|
443
442
|
order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
|
|
444
|
-
payment), `basketTotal` (expected total), and `settlement` (`paid`
|
|
443
|
+
payment), `basketTotal` (expected total), and `settlement` (`paid`).
|
|
445
444
|
Line `amount` is the saved extended amount (unit price × quantity); line `total`
|
|
446
445
|
is its proportionally allocated share of the actual aggregate payment for refunds.
|
|
447
|
-
A
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
repeated delivery of the same session never creates another round.
|
|
446
|
+
A payment that differs from the saved basket total emits `payment.review`, retains
|
|
447
|
+
the actual payment, and shows the customer a staff-review notice. It never emits
|
|
448
|
+
`order.paid` or grants entitlements. Staff reconcile or refund the difference.
|
|
449
|
+
Exact payments emit `order.paid`; deduplicate fulfillment by order ID.
|
|
452
450
|
|
|
453
451
|
The Stripe heading is the connected Stripe account's business name. A sandbox
|
|
454
452
|
account named “Homie sandbox” shows that name; a real pub's account shows the pub's
|
|
455
453
|
name. The basket's product name and description use the pub's own wording.
|
|
454
|
+
|
|
455
|
+
### Payments requiring review
|
|
456
|
+
|
|
457
|
+
A signed paid deposit or basket that cannot match its saved order is retained as a `review` order, with the payment ID and actual total, a customer notice and a durable `payment.review` event. Staff reconcile fulfillment or refund in Stripe. Group payments that exceed remaining capacity follow the same path without granting seats. Repeated events do not duplicate the review order.
|
|
458
|
+
|
|
459
|
+
Full connect saves `.studio/stripe-mcp.json` and public purchase configuration for the build’s JSON asset, just like plugin verification. Rebuild/deploy before retiring old links. `STRIPE_CHECKOUT_MODE` stays in public Wrangler vars; only the webhook signing secret and private purchase signing keys use secrets.
|
package/worker/app-records.mjs
CHANGED
|
@@ -4,9 +4,9 @@ import { moveBooking } from './booking-store.mjs';
|
|
|
4
4
|
import { toolIdentity } from './tool-identity.mjs';
|
|
5
5
|
/** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
|
|
6
6
|
import { appRole, recordProblem } from './app-format.mjs';
|
|
7
|
-
import { isOwner, sameOrigin } from './office.mjs';
|
|
7
|
+
import { appAssetTicket, isOwner, sameOrigin, launchOf, settingsOf } from './office.mjs';
|
|
8
8
|
import { players } from './players.mjs';
|
|
9
|
-
import { ownerAllowed } from './stats.mjs';
|
|
9
|
+
import { cookieValues, ownerAllowed } from './stats.mjs';
|
|
10
10
|
import { appOriginOf } from './standalone.mjs';
|
|
11
11
|
export const APPS_MIGRATION_FILE = '0014_studio_apps.sql';
|
|
12
12
|
export const APPS_MIGRATION = `CREATE TABLE IF NOT EXISTS app_records (
|
|
@@ -52,6 +52,8 @@ export async function appAccess(request, env, meta, url) {
|
|
|
52
52
|
const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
|
|
53
53
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
|
54
54
|
}
|
|
55
|
+
const handoff=cookieValues(request,`studio_app_${meta.id}_${role}`)[0];
|
|
56
|
+
if(handoff&&await appAssetTicket(env,meta.id,handoff,{meta,purpose:'handoff',expectedRole:role,launch:launchOf(meta,await settingsOf(env),env)}))return {ok:true,role,can:declared.can};
|
|
55
57
|
const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
|
|
56
58
|
if (!link || link.token !== url.searchParams.get('access')) return { ok: false, status: 404, error: 'private role link required' };
|
|
57
59
|
if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
|
package/worker/app-shell.mjs
CHANGED
|
@@ -4,7 +4,12 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
4
4
|
if (!boot || boot.kind !== 'app') return;
|
|
5
5
|
var frame = boot.direct ? {contentWindow:window} : document.querySelector('iframe.game') || document.querySelector('iframe');
|
|
6
6
|
var params = new URLSearchParams(location.search);
|
|
7
|
-
//
|
|
7
|
+
// Authorized shell responses have already set the HttpOnly role handoff.
|
|
8
|
+
if(params.has('access')) {
|
|
9
|
+
params.delete('access');
|
|
10
|
+
history.replaceState(null,'',location.pathname+(params.size?'?'+params:'')+(location.hash||''));
|
|
11
|
+
}
|
|
12
|
+
// Share short-lived reads across same-origin screens with the same role and browser cookies.
|
|
8
13
|
// No records are persisted. Writes always reach the Worker and invalidate all views.
|
|
9
14
|
var recent = new Map(), failures = new Map(), pending = new Map();
|
|
10
15
|
var channel; try { channel = new BroadcastChannel('homie-app-records-v1'); } catch (_) {}
|
|
@@ -38,11 +43,11 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
38
43
|
var live, collections=new Set(), presence=0, clock=null;
|
|
39
44
|
function query() {
|
|
40
45
|
var q=new URLSearchParams();
|
|
41
|
-
['role','surface','
|
|
46
|
+
['role','surface','room'].forEach(function(k){var v=params.get(k);if(v)q.set(k,v);});
|
|
42
47
|
if(boot.screen&&!q.has('role'))q.set('surface','wall');
|
|
43
48
|
return q;
|
|
44
49
|
}
|
|
45
|
-
function context() {return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open',presence:presence,now:clock};}
|
|
50
|
+
function context() {var room=new URLSearchParams(location.search).get('room');return {app:boot.game,origin:location.origin,url:location.origin+'/'+boot.game+'/open'+(room&&/^[A-Za-z0-9_-]{1,32}$/.test(room)?'?room='+encodeURIComponent(room):''),presence:presence,now:clock};}
|
|
46
51
|
function sendContext(){frame?.contentWindow?.postMessage({type:'homie-app-context',context:context()},'*');}
|
|
47
52
|
function connect(){
|
|
48
53
|
live?.close();
|
|
@@ -63,7 +68,7 @@ export const APP_SHELL_JS = String.raw`(function () {
|
|
|
63
68
|
if (!frame || e.source !== frame.contentWindow || !m || !['homie-app','homie-app-tool'].includes(m.type) || !/^[a-f0-9]{32}$/.test(m.id || '')) return;
|
|
64
69
|
if (m.type==='homie-app' && (!/^[a-z][a-z0-9-]{0,39}$/.test(m.collection || '') || !['GET','POST','PUT','DELETE'].includes(m.method) || (m.record !== undefined && !/^[A-Za-z0-9_-]{1,64}$/.test(m.record)))) return;
|
|
65
70
|
var q = new URLSearchParams();
|
|
66
|
-
['role','surface'
|
|
71
|
+
['role','surface'].forEach(function (k) { var v = params.get(k); if (v) q.set(k, v); });
|
|
67
72
|
if (boot.screen && !q.has('role')) q.set('surface', 'wall');
|
|
68
73
|
var path = (boot.site || '') + '/' + boot.game + '/api/app/records/' + m.collection + (m.record ? '/' + m.record : '') + '?' + q;
|
|
69
74
|
if(m.type==='homie-app-tool')path=(boot.site||'')+'/'+boot.game+'/api/app/tools?'+q;
|