@homie-rocks/studio 0.37.1 → 0.38.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 +60 -0
- package/bin/homie-studio.mjs +7 -6
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/perf.mjs +17 -10
- package/lib/port-check.mjs +77 -18
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +25 -5
- package/lib/shoot.mjs +12 -6
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +85 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- package/worker/version.mjs +1 -1
- package/starters/gem-rush/src/main.ts +0 -1087
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { hasStripeKey, shopMode } from './shop-links.mjs';
|
|
2
|
+
import { hostedReadiness } from './purchase-hosted-readiness.mjs';
|
|
3
|
+
import { listOffers } from './resource-kinds.mjs';
|
|
4
|
+
import { orderFact, recordOrderState } from './purchase-core.mjs';
|
|
5
|
+
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
6
|
+
/** Studio licences use the shop's Stripe, order book, webhook and refund office. */
|
|
7
|
+
|
|
8
|
+
import { canonicalJson } from './referrals.mjs';
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
cancelSubscription,
|
|
12
|
+
closeCheckoutSession,
|
|
13
|
+
retrieveCheckoutSession,
|
|
14
|
+
stripeCall,
|
|
15
|
+
modeOf,
|
|
16
|
+
} from "./stripe.mjs";
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
json,
|
|
20
|
+
byId,
|
|
21
|
+
idOf,
|
|
22
|
+
safeId,
|
|
23
|
+
validBuyer,
|
|
24
|
+
termsOf,
|
|
25
|
+
purchasedResource,
|
|
26
|
+
listedRelease,
|
|
27
|
+
recurringReady,
|
|
28
|
+
expireUnpaidMachineOrders,
|
|
29
|
+
} from "./purchase-store.mjs";
|
|
30
|
+
import {
|
|
31
|
+
reconcilePurchaseSession,
|
|
32
|
+
reconcileSubscription,
|
|
33
|
+
reconcileOrder,
|
|
34
|
+
} from "./purchase-reconciliation.mjs";
|
|
35
|
+
/** Owner-authenticated office calls this after matching the payer's Stripe receipt. */
|
|
36
|
+
export async function reissuePurchaseClaim(env, body) {
|
|
37
|
+
if (
|
|
38
|
+
!validBuyer(body.claimHash) ||
|
|
39
|
+
!/^ord_[A-Za-z0-9]{20}$/.test(body.order ?? "") ||
|
|
40
|
+
body.receiptVerified !== true
|
|
41
|
+
)
|
|
42
|
+
return {
|
|
43
|
+
ok: false,
|
|
44
|
+
message:
|
|
45
|
+
"Verify the Stripe receipt, then supply order, claimHash and receiptVerified: true",
|
|
46
|
+
};
|
|
47
|
+
const o = await byId(env, body.order);
|
|
48
|
+
if (o && o.mode !== shopMode(env))
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
message: "Reconnect the purchase payment mode before reissue",
|
|
52
|
+
};
|
|
53
|
+
if (!o?.session && !o?.payment)
|
|
54
|
+
return { ok: false, message: "No provider payment to verify" };
|
|
55
|
+
const receipt = o.session
|
|
56
|
+
? await retrieveCheckoutSession(env, o.session)
|
|
57
|
+
: await stripeCall(
|
|
58
|
+
env,
|
|
59
|
+
"GET",
|
|
60
|
+
`/v1/payment_intents/${encodeURIComponent(o.payment)}`,
|
|
61
|
+
);
|
|
62
|
+
if (
|
|
63
|
+
receipt.metadata?.order !== o.id ||
|
|
64
|
+
receipt.metadata?.homie !== "purchase-v1" ||
|
|
65
|
+
(o.session && (idOf(receipt.customer) ?? null) !== o.customer)
|
|
66
|
+
)
|
|
67
|
+
return {
|
|
68
|
+
ok: false,
|
|
69
|
+
message: "Stripe receipt does not match this purchase",
|
|
70
|
+
};
|
|
71
|
+
if (body.buyer && o.subscription)
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
message:
|
|
75
|
+
"A subscription cannot move its payer through claim reissue. Cancel billing in Stripe and have the new studio approve its own Checkout.",
|
|
76
|
+
};
|
|
77
|
+
if (body.buyer && (!termsOf(o).transferable || !validBuyer(body.buyer)))
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
message: "These licence terms do not permit this transfer",
|
|
81
|
+
};
|
|
82
|
+
await orderFact(env, o.id, 'details', {claim_hash: body.claimHash, buyer: body.buyer ?? o.buyer})
|
|
83
|
+
.run();
|
|
84
|
+
return {
|
|
85
|
+
ok: true,
|
|
86
|
+
order: o.id,
|
|
87
|
+
message:
|
|
88
|
+
"Claim reissued. Earlier download credentials and claims are revoked. The buyer retries the purchase.",
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export async function refundPurchasePayment(env, order) {
|
|
93
|
+
const o = await byId(env, order.id);
|
|
94
|
+
if (!o?.subscription) return false;
|
|
95
|
+
await env.DB.prepare(
|
|
96
|
+
"UPDATE purchase_payment_facts SET state = 'refunded' WHERE payment = ?1 AND order_id = ?2",
|
|
97
|
+
)
|
|
98
|
+
.bind(o.payment, o.id)
|
|
99
|
+
.run();
|
|
100
|
+
await reconcileSubscription(env, o);
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Owner-only status probe; player readiness never depends on resource permissions. */
|
|
105
|
+
export async function purchaseReadiness(env, origin, catalogue = {}) {
|
|
106
|
+
const missing = [];
|
|
107
|
+
const recurring = (await listOffers(env,origin)).some((r)=>r.offer.billing !== 'one-time');
|
|
108
|
+
if (recurring && !(await recurringReady(env)))
|
|
109
|
+
missing.push(
|
|
110
|
+
"Reconnect the shop to verify subscription permissions and webhook events",
|
|
111
|
+
);
|
|
112
|
+
if (!env.PURCHASE_RATE_LIMITER)
|
|
113
|
+
missing.push("Deploy the Cloudflare purchase rate limiting binding");
|
|
114
|
+
if (!env.PURCHASE_SIGNING_KEYS)
|
|
115
|
+
missing.push("Create the separate purchase signing key");
|
|
116
|
+
const machine = await machineCapabilities(env);
|
|
117
|
+
if (!machine.ready) missing.push(...Object.values(machine.reasons));
|
|
118
|
+
const { dryPaymentExchange } = await import('./payment-dry-exchange.mjs');
|
|
119
|
+
let exchange = machine.ready ? await dryPaymentExchange(env, origin, catalogue) : { verified: false, reason: 'Configure machine payments before running the dry exchange.' };
|
|
120
|
+
if (shopMode(env) === 'test') {
|
|
121
|
+
const cached = await env.DB.prepare("SELECT value FROM meta WHERE key = 'purchase-test-exchange'").first();
|
|
122
|
+
const sandbox = cached ? JSON.parse(cached.value) : null;
|
|
123
|
+
if (sandbox && sandbox.nextCheckAt > Date.now()) exchange.sandbox = sandbox;
|
|
124
|
+
// Readiness is the current read-only exchange, not a permanent historical probe result.
|
|
125
|
+
}
|
|
126
|
+
if (machine.ready && !exchange.verified) missing.push(exchange.reason);
|
|
127
|
+
const fallback = await hostedReadiness(env, catalogue, recurring);
|
|
128
|
+
return { ready: !missing.length, missing, machine: { ...machine, ready: !missing.length }, fallback, exchange };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Close new sales immediately, then cancel every recurring contract; retries resume safely. */
|
|
132
|
+
export async function retireResource(env, kind, resource) {
|
|
133
|
+
if (!safeId(resource)) return { ok: false, message: "Name the resource to retire" };
|
|
134
|
+
await env.DB.prepare(
|
|
135
|
+
"INSERT OR IGNORE INTO resource_retirements (resource,retired_at) VALUES (?1,?2)",
|
|
136
|
+
)
|
|
137
|
+
.bind(`${shopMode(env)}:${kind}:${resource}`, Date.now())
|
|
138
|
+
.run();
|
|
139
|
+
await expirePurchaseCheckouts(env, kind, resource);
|
|
140
|
+
let after = "";
|
|
141
|
+
let canceled = 0;
|
|
142
|
+
const pending = [];
|
|
143
|
+
for (;;) {
|
|
144
|
+
const rows =
|
|
145
|
+
(
|
|
146
|
+
await env.DB.prepare(
|
|
147
|
+
"SELECT id AS order_id, subscription FROM purchase_orders WHERE item = ?1 AND mode = ?3 AND subscription IS NOT NULL AND id > ?2 ORDER BY id LIMIT 100",
|
|
148
|
+
)
|
|
149
|
+
.bind(`${kind}:${resource}`, after, shopMode(env))
|
|
150
|
+
.all()
|
|
151
|
+
).results ?? [];
|
|
152
|
+
if (!rows.length) break;
|
|
153
|
+
for (const row of rows) {
|
|
154
|
+
if (hasStripeKey(env)) { await cancelSubscription(env, row.subscription); canceled++; }
|
|
155
|
+
else {
|
|
156
|
+
const saved = await env.DB.prepare('SELECT value FROM meta WHERE key = ?1').bind(`purchase-subscription:${row.subscription}`).first();
|
|
157
|
+
if (saved && JSON.parse(saved.value).status === 'canceled') canceled++;
|
|
158
|
+
else pending.push(row.subscription);
|
|
159
|
+
}
|
|
160
|
+
after = row.order_id;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (pending.length) return {ok:false,needs:'stripe-cancellation',newSalesClosed:true,subscriptions:pending,
|
|
164
|
+
message:'Ask the connected AI to cancel these subscriptions in your Stripe account, resend their signed subscription events if needed, then retry retirement. Paid-period downloads remain available.',
|
|
165
|
+
next:pending.map(id=>({method:'DELETE',path:`/v1/subscriptions/${id}`}))};
|
|
166
|
+
return {
|
|
167
|
+
ok: true,
|
|
168
|
+
canceled,
|
|
169
|
+
message:
|
|
170
|
+
"New sales are closed and subscriptions canceled. Keep paid-period downloads available and publish the withdrawal.",
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Owner approval binds a test delivery to the secret claim, not a public order identifier. */
|
|
175
|
+
export async function authorizePurchaseTest(env, order) {
|
|
176
|
+
const o = await byId(env, order);
|
|
177
|
+
if (!o || o.mode !== "test" || shopMode(env) !== "test")
|
|
178
|
+
return { ok: false, message: "Only a current test order can be approved" };
|
|
179
|
+
await env.DB.prepare("INSERT OR REPLACE INTO purchase_test_approvals (order_id,value) VALUES (?1,?2)")
|
|
180
|
+
.bind(o.id, o.claim_hash)
|
|
181
|
+
.run();
|
|
182
|
+
return {
|
|
183
|
+
ok: true,
|
|
184
|
+
message:
|
|
185
|
+
"This owner-approved test claim can download while the shop stays in test mode",
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
/** Called on retirement and after deployment, including withdrawal, supersession and price changes. */
|
|
189
|
+
export async function expirePurchaseCheckouts(env, kind = null, resource = null) {
|
|
190
|
+
let after = "";
|
|
191
|
+
let expired = 0;
|
|
192
|
+
for (;;) {
|
|
193
|
+
const rows =
|
|
194
|
+
(
|
|
195
|
+
await env.DB.prepare(
|
|
196
|
+
"SELECT id, session FROM purchase_orders WHERE status = 'started' AND mode = ?1 AND id > ?2 ORDER BY id LIMIT 100",
|
|
197
|
+
)
|
|
198
|
+
.bind(shopMode(env), after)
|
|
199
|
+
.all()
|
|
200
|
+
).results ?? [];
|
|
201
|
+
if (!rows.length) break;
|
|
202
|
+
for (const row of rows) {
|
|
203
|
+
after = row.id;
|
|
204
|
+
const o = await byId(env, row.id);
|
|
205
|
+
const p = purchasedResource(o);
|
|
206
|
+
if (resource && (resource !== p.id || kind !== o.resource_kind)) continue;
|
|
207
|
+
const listed = resource
|
|
208
|
+
? null
|
|
209
|
+
: await listedRelease(env, "https://studio.invalid", o.resource_kind, p.id, p.version);
|
|
210
|
+
if (
|
|
211
|
+
Date.now() < o.created_at + (o.session ? 86400000 : 300000) &&
|
|
212
|
+
listed &&
|
|
213
|
+
canonicalJson(listed.offer) === canonicalJson(JSON.parse(o.offer))
|
|
214
|
+
)
|
|
215
|
+
continue;
|
|
216
|
+
// A keyless completion can precede its invoice. Wait for signed facts; no provider read.
|
|
217
|
+
if (row.session && !hasStripeKey(env)) continue;
|
|
218
|
+
const session = row.session ? await closeCheckoutSession(env, row.session) : { status: "expired" };
|
|
219
|
+
if (session.status === "complete")
|
|
220
|
+
await reconcilePurchaseSession(env, session);
|
|
221
|
+
else {
|
|
222
|
+
await orderFact(env, o.id, 'expired', {})
|
|
223
|
+
.run();
|
|
224
|
+
|
|
225
|
+
expired++;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
const cleaned = await expireUnpaidMachineOrders(env);
|
|
230
|
+
return { ok: true, expired, cleaned };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Owner actions refresh recurring state just as a buyer grant does. */
|
|
234
|
+
export async function currentPurchaseOrder(env, id) {
|
|
235
|
+
const { reconcileRecordings } = await import('./payment-recovery.mjs');
|
|
236
|
+
await reconcileRecordings(env, id);
|
|
237
|
+
let o = await byId(env, id);
|
|
238
|
+
if (o && o.mode === shopMode(env)) {
|
|
239
|
+
await reconcileOrder(env, o);
|
|
240
|
+
o = await byId(env, id);
|
|
241
|
+
}
|
|
242
|
+
return o;
|
|
243
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { currencyScale } from './shop-rules.mjs';
|
|
2
|
+
export function priceWords(s, quantity = 1) {
|
|
3
|
+
if (!s) return 'Free';
|
|
4
|
+
const currency = String(s.currency).toUpperCase();
|
|
5
|
+
const digits = Math.log10(currencyScale(s.currency));
|
|
6
|
+
const shown = new Intl.NumberFormat('en', { style: 'currency', currency, currencyDisplay: 'code' }).format(s.amount * quantity / 10 ** digits);
|
|
7
|
+
return `${shown}${s.billing === 'one-time' ? ' once' : ` per ${s.billing}`} ${s.taxBehavior === 'inclusive' ? 'including tax' : (s.automaticTax || s.taxBehavior === 'exclusive') ? 'before final tax calculation' : 'total'} · per ${s.scope}${quantity > 1 ? ` · ${quantity} units` : ''}`;
|
|
8
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
const titles = { 400: 'Bad Request', 401: 'Unauthorized', 402: 'Payment Required', 403: 'Forbidden', 404: 'Not Found', 405: 'Method Not Allowed', 409: 'Conflict', 410: 'Gone', 413: 'Content Too Large', 429: 'Too Many Requests', 500: 'Internal Server Error', 503: 'Service Unavailable' };
|
|
2
|
+
export function problem(status, detail, extra = {}) {
|
|
3
|
+
return Response.json({ type: 'about:blank', title: titles[status] ?? 'Error', status, detail, ok: false, message: detail, ...extra }, { status, headers: { 'content-type': 'application/problem+json', 'cache-control': 'no-store' } });
|
|
4
|
+
}
|