@homie-rocks/studio 0.58.23 → 0.58.24
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 +10 -0
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/worker/booking-holds.mjs +14 -3
- package/worker/booking-provider.mjs +54 -0
- package/worker/build-id.mjs +1 -1
- package/worker/shop.mjs +10 -6
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,16 @@ 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.58.24] - 2026-10-29
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.15**
|
|
23
|
+
|
|
24
|
+
A studio Homie approved for test payments takes booking payments in Stripe TEST mode.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Booking checkout (`studio_checkout`, Venue desk `venue_booking_confirm`) opens a Stripe TEST Checkout page on Homie's sandbox account when the studio's `HOMIE_PAYMENTS` binding says test payments are open (the owner asks Talk to Homie, the Homie Team approves). Paying with a Stripe test card confirms the booking. Every other studio's booking checkout stays `closed`; no real money moves.
|
|
29
|
+
|
|
20
30
|
## [0.58.23] - 2026-10-29
|
|
21
31
|
|
|
22
32
|
**Plugin 0.58.15**
|
|
@@ -4950,6 +4950,48 @@
|
|
|
4950
4950
|
}
|
|
4951
4951
|
}
|
|
4952
4952
|
}
|
|
4953
|
+
},
|
|
4954
|
+
"0.58.24": {
|
|
4955
|
+
"files": {
|
|
4956
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4957
|
+
"README.md": "684b3cca8001673c",
|
|
4958
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4959
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4960
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4961
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4962
|
+
"posts/README.md": "37841aae476bd540",
|
|
4963
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4964
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4965
|
+
},
|
|
4966
|
+
"sections": {
|
|
4967
|
+
"AGENTS.md": {
|
|
4968
|
+
"(intro)": "af7f2f732505d379",
|
|
4969
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4970
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4971
|
+
"## Apps: one screen, roles and parts": "2ecf713305fd26f5",
|
|
4972
|
+
"## Commands (all through the pinned CLI in node_modules)": "661cbdf5d6a9dbae",
|
|
4973
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4974
|
+
"## Making games": "644c1e7608bb9740",
|
|
4975
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4976
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4977
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4978
|
+
"## Servers and AI seats": "1904f45b599a33e4",
|
|
4979
|
+
"## Selling things (the shop)": "e5bd380cb77f2b11",
|
|
4980
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4981
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4982
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4983
|
+
"## Beta": "9d02638207a26e73"
|
|
4984
|
+
}
|
|
4985
|
+
},
|
|
4986
|
+
"named": {
|
|
4987
|
+
"Homie Arcade": {
|
|
4988
|
+
"sections": {
|
|
4989
|
+
"AGENTS.md": {
|
|
4990
|
+
"## Commands (all through the pinned CLI in node_modules)": "0a7b2c36414a29a0"
|
|
4991
|
+
}
|
|
4992
|
+
}
|
|
4993
|
+
}
|
|
4994
|
+
}
|
|
4953
4995
|
}
|
|
4954
4996
|
}
|
|
4955
4997
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.24",
|
|
4
4
|
"description": "Open-source tools that let your AI build and run a multiplayer game studio on its own Cloudflare: an MCP server, multiplayer starters, Workers and Durable Objects.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/worker/booking-holds.mjs
CHANGED
|
@@ -3,6 +3,7 @@ import {loadAndAllocateResources} from './booking-resources.mjs';
|
|
|
3
3
|
import {normalizeBookingArgs} from './booking-options.mjs';
|
|
4
4
|
import {BOOKINGS_MIGRATION} from './booking-store.mjs';
|
|
5
5
|
import {linkConfig,hasStripeKey} from './shop-links.mjs';
|
|
6
|
+
import {syncProviderOrder} from './booking-provider.mjs';
|
|
6
7
|
export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
|
|
7
8
|
export const BOOKING_HOLDS_MIGRATION=`
|
|
8
9
|
ALTER TABLE shop_bookings ADD COLUMN paid_at INTEGER;
|
|
@@ -62,7 +63,7 @@ export function bookingHolds(env,caller,cat){
|
|
|
62
63
|
const changed=await env.DB.prepare(`UPDATE shop_bookings SET state=?2,version=version+1,updated_at=?3 WHERE id=?1 AND version=?4 AND state IN ('held','confirmed') AND (?2='released' OR hold_until>?3) RETURNING *`).bind(row.id,state,Date.now(),args.version).first();
|
|
63
64
|
if(!changed)throw refusal('Booking changed or expired; reload',409,'hold_changed_or_expired');return result(changed);
|
|
64
65
|
};
|
|
65
|
-
|
|
66
|
+
const holds=Object.freeze({
|
|
66
67
|
calendar({app,...options}){const meta=cat.games.find(g=>g.id===app&&g.kind==='app');if(!meta)throw refusal('Unknown app');return calendarSlots({timeZone:meta.timeZone,...options});},
|
|
67
68
|
async hold(input){
|
|
68
69
|
const args=normalizeBookingArgs(input);
|
|
@@ -107,9 +108,18 @@ export function bookingHolds(env,caller,cat){
|
|
|
107
108
|
if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token',400,'invalid_token');
|
|
108
109
|
if(!token&&!caller.id)throw refusal('Pass the private token you gave studio_book',400,'token_required');
|
|
109
110
|
const rows=(await env.DB.prepare(`SELECT * FROM shop_bookings WHERE (${token?'token_hash=?1':'actor=?1'}) AND (?2 IS NULL OR app=?2 OR app LIKE ?2||':check:%') AND (?3=0 OR (state='confirmed' OR state='held' AND hold_until>?4)) ORDER BY updated_at DESC LIMIT 50`).bind(token?await hash(token):caller.id,app??null,active?1:0,Date.now()).all()).results??[];
|
|
110
|
-
|
|
111
|
+
const own=rows.filter(r=>!caller.testScope||r.app.endsWith(':check:'+caller.testScope));
|
|
112
|
+
let synced=false;
|
|
113
|
+
for(const r of own.filter(r=>r.state==='held'&&typeof r.order_id==='string'&&r.order_id.startsWith('ord_')).slice(0,3))synced=await syncProviderOrder(env,cat,r.order_id)||synced;
|
|
114
|
+
if(synced)return holds.mine({token,app,active});
|
|
115
|
+
return own.map(result);
|
|
116
|
+
},
|
|
117
|
+
async get(args){
|
|
118
|
+
let row=await ownedHold(env,caller,args);
|
|
119
|
+
// A booking paid through the host's checkout provider is recorded when its owner next looks (booking-provider.mjs).
|
|
120
|
+
if(row.state==='held'&&typeof row.order_id==='string'&&row.order_id.startsWith('ord_')&&await syncProviderOrder(env,cat,row.order_id))row=await ownedHold(env,caller,args);
|
|
121
|
+
return result(row);
|
|
111
122
|
},
|
|
112
|
-
async get(args){return result(await ownedHold(env,caller,args));},
|
|
113
123
|
confirm:args=>change(args,'confirmed'),release:args=>change(args,'released'),
|
|
114
124
|
async releaseExpired({app,collection,now=Date.now()}){
|
|
115
125
|
if(!caller.owner)throw refusal('Owner or scheduled function required');
|
|
@@ -118,6 +128,7 @@ export function bookingHolds(env,caller,cat){
|
|
|
118
128
|
const r=await env.DB.prepare("UPDATE shop_bookings SET state='released',version=version+1,updated_at=?3 WHERE app=?1 AND collection=?2 AND order_id='hold:'||id AND state='held' AND hold_until<=?3").bind(scope,collection,now).run();return {released:r.meta.changes};
|
|
119
129
|
}
|
|
120
130
|
});
|
|
131
|
+
return holds;
|
|
121
132
|
}
|
|
122
133
|
/** Used in the same transaction as the order reservation. A second checkout cannot claim the hold. */
|
|
123
134
|
export function claimHoldStatement(env,{hold,app,collection,slots,slot,quantity,order,now=Date.now()}){
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { hasStripeKey } from './shop-links.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* A HOSTED BOOKING CHECKOUT PROVIDER (0.58.24). A host may bind HOMIE_PAYMENTS, a service that opens Stripe Checkout
|
|
4
|
+
* for a studio's bookings on the host's own Stripe account (Homie's managed hosting does, in Stripe test mode, for
|
|
5
|
+
* studios its operator approved). With no such binding, or with the studio's own STRIPE_KEY, nothing here changes
|
|
6
|
+
* anything: the studio's own Stripe and Payment Links paths run as before. Every call fails closed: a missing method,
|
|
7
|
+
* a thrown call or any answer but `true` means the provider is not open.
|
|
8
|
+
*
|
|
9
|
+
* Provider contract (all plain JSON):
|
|
10
|
+
* bookingCheckoutOpen() -> true when this studio may open a booking checkout now
|
|
11
|
+
* bookingCheckout(params) -> { id: 'cs_…', url: 'https://…', expires_at, livemode } from the toolkit's own
|
|
12
|
+
* Checkout Session params (sessionParams)
|
|
13
|
+
* bookingSession(id) -> the Checkout Session (Stripe's shape, payment_intent expanded) or null
|
|
14
|
+
*/
|
|
15
|
+
const META = (order) => `booking-provider:${order}`;
|
|
16
|
+
|
|
17
|
+
export async function providerOpen(env) {
|
|
18
|
+
if (!env?.HOMIE_PAYMENTS || hasStripeKey(env)) return false;
|
|
19
|
+
try { return (await env.HOMIE_PAYMENTS.bookingCheckoutOpen()) === true; } catch { return false; }
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Opens the session through the provider and remembers that this order belongs to it. */
|
|
23
|
+
export async function providerCheckout(env, orderId, params) {
|
|
24
|
+
const session = await env.HOMIE_PAYMENTS.bookingCheckout(JSON.parse(JSON.stringify(params)));
|
|
25
|
+
if (session?.state === 'rejected') throw Object.assign(new Error('The checkout is not open for this studio'), { code: 'closed' });
|
|
26
|
+
if (typeof session?.id !== 'string' || !/^cs_(test|live)_[A-Za-z0-9]+$/.test(session.id)) throw new Error('The payment provider answered without a session');
|
|
27
|
+
await env.DB.prepare('INSERT OR REPLACE INTO meta(key,value) VALUES(?1,?2)').bind(META(orderId), JSON.stringify({ session: session.id, livemode: session.livemode === true })).run();
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Asks the provider whether one booking order was paid, and records it with the shop's own `paid` checks (order,
|
|
33
|
+
* buyer, currency, amount and tax must match). True when the order changed.
|
|
34
|
+
*/
|
|
35
|
+
export async function syncProviderOrder(env, cat, orderId) {
|
|
36
|
+
if (typeof orderId !== 'string' || !/^ord_[A-Za-z0-9]{8,64}$/.test(orderId) || !env?.HOMIE_PAYMENTS || !env.DB) return false;
|
|
37
|
+
try {
|
|
38
|
+
const meta = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(META(orderId)).first();
|
|
39
|
+
if (!meta) return false;
|
|
40
|
+
const saved = JSON.parse(meta.value);
|
|
41
|
+
const order = await env.DB.prepare('SELECT id,session,status,mode FROM shop_orders WHERE id=?1').bind(orderId).first();
|
|
42
|
+
if (!order || order.session !== saved.session || !['started', 'processing'].includes(order.status)) return false;
|
|
43
|
+
const session = await env.HOMIE_PAYMENTS.bookingSession(order.session);
|
|
44
|
+
if (!session || session.id !== order.session || session.livemode !== (order.mode === 'live') || session.metadata?.order !== orderId) return false;
|
|
45
|
+
if (session.status !== 'complete' || !['paid', 'no_payment_required'].includes(session.payment_status)) return false;
|
|
46
|
+
const { paid, shopOf } = await import('./shop.mjs');
|
|
47
|
+
const at = (session.payment_intent?.latest_charge?.created ?? session.payment_intent?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000;
|
|
48
|
+
const outcome = await paid(env, shopOf(cat), session, at);
|
|
49
|
+
return outcome !== 'mismatch' && outcome !== 'unknown-order';
|
|
50
|
+
} catch (error) {
|
|
51
|
+
console.warn('Booking provider sync', { order: orderId, reason: String(error?.message ?? error).slice(0, 200) });
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.24","sha256":"e554d72f5dbf3c334bc37e6867fc1e92465a3648996ddc0fd497d4f7c014e601"});
|
package/worker/shop.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-
|
|
|
6
6
|
import { bookingStatement } from './booking-store.mjs';
|
|
7
7
|
import { paymentRefunds } from './shop-refunds.mjs';
|
|
8
8
|
import { selling } from './extensions.mjs';
|
|
9
|
+
import { providerOpen, providerCheckout } from './booking-provider.mjs';
|
|
9
10
|
/**
|
|
10
11
|
* THE SHOP (@homie-rocks/studio 0.24.0): a studio sells with ITS OWN Stripe account (shop/SHOP.md is the guide).
|
|
11
12
|
* The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
|
|
@@ -514,9 +515,10 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
|
|
|
514
515
|
}
|
|
515
516
|
|
|
516
517
|
/** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
|
|
517
|
-
async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now() }) {
|
|
518
|
+
async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now(), provider = false }) {
|
|
518
519
|
lines ??= [{ item, amount, quantity: 1 }];
|
|
519
|
-
|
|
520
|
+
if (provider && (!booking || lines.some(l => l.item.interval))) provider = false;
|
|
521
|
+
const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env) && !provider;
|
|
520
522
|
const config=linkConfig(env);
|
|
521
523
|
if(booking?.hold&&keyless)return {error:fail(409,'booking','A held slot needs session checkout; keyless payment links cannot claim it.')};
|
|
522
524
|
if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
|
|
@@ -595,9 +597,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
595
597
|
try {
|
|
596
598
|
const checkoutParams = params();
|
|
597
599
|
if (booking) await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`booking-checkout:${order.id}`, JSON.stringify(checkoutParams)).run();
|
|
598
|
-
session = await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
|
|
600
|
+
session = provider ? await providerCheckout(env, order.id, checkoutParams) : await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
|
|
599
601
|
} catch (error) {
|
|
600
|
-
if (!useCatalog || !isMissingProduct(error)) throw error;
|
|
602
|
+
if (provider || !useCatalog || !isMissingProduct(error)) throw error;
|
|
601
603
|
for (const line of lines) line.product = null;
|
|
602
604
|
session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}-inline` });
|
|
603
605
|
}
|
|
@@ -634,7 +636,9 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
634
636
|
const reference = b.body.reference ?? null;
|
|
635
637
|
if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
|
|
636
638
|
const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
|
|
637
|
-
|
|
639
|
+
// A host's booking checkout provider (booking-provider.mjs) takes the place of the studio's own Stripe setup, for bookings only.
|
|
640
|
+
const provider = b.body.booking !== undefined && !ready.ready && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout', 'stripe-key'].includes(m)) && await providerOpen(env);
|
|
641
|
+
if (!ready.ready && !provider && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
|
|
638
642
|
const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
|
|
639
643
|
// Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
|
|
640
644
|
if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
|
|
@@ -694,7 +698,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
|
|
|
694
698
|
}catch{return fail(403,'booking','Booking owner required');}
|
|
695
699
|
}
|
|
696
700
|
}
|
|
697
|
-
const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
|
|
701
|
+
const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt, provider: provider && !!booking });
|
|
698
702
|
const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: booking.hold??r.order.id } : {}) });
|
|
699
703
|
if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
|
|
700
704
|
for (const cookie of cookies) response.headers.append('set-cookie', cookie);
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.24';
|