@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,611 @@
|
|
|
1
|
+
import { purchaseLink } from './purchase-links.mjs';
|
|
2
|
+
import { hasStripeKey, shopMode } from './shop-links.mjs';
|
|
3
|
+
import { hostedReadiness } from './purchase-hosted-readiness.mjs';
|
|
4
|
+
import { orderFact, orderState, recordOrderState, offerVersion } from './purchase-core.mjs';
|
|
5
|
+
/** Studio licences use the shop's Stripe, order book, webhook and refund office. */
|
|
6
|
+
|
|
7
|
+
import { canonicalJson } from './referrals.mjs';
|
|
8
|
+
import { signingKey, publicKeys } from './purchase-keys.mjs';
|
|
9
|
+
import {
|
|
10
|
+
createCheckoutSession,
|
|
11
|
+
retrieveCheckoutSession,
|
|
12
|
+
stripeCall,
|
|
13
|
+
modeOf,
|
|
14
|
+
} from "./stripe.mjs";
|
|
15
|
+
import { newOrderId } from './shop-store.mjs';
|
|
16
|
+
import { esc } from './site.mjs';
|
|
17
|
+
import { digest, manifestHash, verifyGrant } from './purchase-crypto.mjs';
|
|
18
|
+
import { priceWords } from './purchase-pricing.mjs';
|
|
19
|
+
import { resourceKind, hasResourceKind } from './resource-kinds.mjs';
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
json,
|
|
23
|
+
fail,
|
|
24
|
+
byId,
|
|
25
|
+
byClaim,
|
|
26
|
+
safeId,
|
|
27
|
+
versionOf,
|
|
28
|
+
validBuyer,
|
|
29
|
+
bearer,
|
|
30
|
+
termsOf,
|
|
31
|
+
active,
|
|
32
|
+
bodyOf,
|
|
33
|
+
limitedText,
|
|
34
|
+
listedRelease,
|
|
35
|
+
resourceAvailable,
|
|
36
|
+
deliveryAllowed,
|
|
37
|
+
} from "./purchase-store.mjs";
|
|
38
|
+
import {
|
|
39
|
+
reconcilePurchaseSession,
|
|
40
|
+
reconcileSubscription,
|
|
41
|
+
reconcileOrder,
|
|
42
|
+
} from "./purchase-reconciliation.mjs";
|
|
43
|
+
import { createPurchase } from './purchase-store.mjs';
|
|
44
|
+
import { grantPurchase } from './purchase-delivery.mjs';
|
|
45
|
+
export function purchaseSessionParams(o, origin, till) {
|
|
46
|
+
const p = JSON.parse(o.manifest);
|
|
47
|
+
const s = o.offer ? JSON.parse(o.offer) : p.sale;
|
|
48
|
+
const recurring = s.billing !== "one-time";
|
|
49
|
+
const metadata = { homie: "purchase-v1", order: o.id };
|
|
50
|
+
return {
|
|
51
|
+
mode: recurring ? "subscription" : "payment",
|
|
52
|
+
client_reference_id: o.buyer,
|
|
53
|
+
line_items: [
|
|
54
|
+
{
|
|
55
|
+
quantity: o.quantity,
|
|
56
|
+
price_data: {
|
|
57
|
+
currency: s.currency,
|
|
58
|
+
unit_amount: s.amount,
|
|
59
|
+
...(s.taxBehavior ? { tax_behavior: s.taxBehavior } : {}),
|
|
60
|
+
...(recurring ? { recurring: { interval: s.billing } } : {}),
|
|
61
|
+
product_data: {
|
|
62
|
+
name: `${p.name} ${p.version}`,
|
|
63
|
+
...(s.taxCode ? { tax_code: s.taxCode } : {}),
|
|
64
|
+
metadata: { resource: p.id, kind: o.resource_kind, version: p.version },
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
},
|
|
68
|
+
],
|
|
69
|
+
metadata,
|
|
70
|
+
...(o.expires_at ? { expires_at: o.expires_at } : {}),
|
|
71
|
+
...(recurring
|
|
72
|
+
? { subscription_data: { metadata } }
|
|
73
|
+
: {
|
|
74
|
+
payment_intent_data: { metadata },
|
|
75
|
+
...(s.customerCreation ? { customer_creation: "always" } : {}),
|
|
76
|
+
}),
|
|
77
|
+
...(till === "stripe-managed"
|
|
78
|
+
? { managed_payments: { enabled: true } }
|
|
79
|
+
: {
|
|
80
|
+
...(s.taxIdCollection
|
|
81
|
+
? { tax_id_collection: { enabled: true } }
|
|
82
|
+
: {}),
|
|
83
|
+
...(!recurring && s.invoiceCreation
|
|
84
|
+
? { invoice_creation: { enabled: true } }
|
|
85
|
+
: {}),
|
|
86
|
+
automatic_tax: { enabled: s.automaticTax === true },
|
|
87
|
+
...(s.adaptivePricing === undefined
|
|
88
|
+
? {}
|
|
89
|
+
: { adaptive_pricing: { enabled: s.adaptivePricing } }),
|
|
90
|
+
}),
|
|
91
|
+
allow_promotion_codes: s.promotionCodes === true,
|
|
92
|
+
success_url: `${origin}/purchases/${o.id}?complete=1`,
|
|
93
|
+
cancel_url: `${origin}/purchases/${o.id}`,
|
|
94
|
+
custom_text: {
|
|
95
|
+
submit: {
|
|
96
|
+
message:
|
|
97
|
+
`${s.immediateDelivery ? "I request immediate digital delivery and acknowledge that my withdrawal right ends when delivery begins. " : ""}Licence: ${s.scope}${o.game ? ` (${o.game})` : ""}. ${s.refund}`.slice(
|
|
98
|
+
0,
|
|
99
|
+
1200,
|
|
100
|
+
),
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** All paths are within shop routing except JWKS (the publication router delegates it). */
|
|
107
|
+
export async function purchaseRoutes(
|
|
108
|
+
request,
|
|
109
|
+
env,
|
|
110
|
+
url,
|
|
111
|
+
cat,
|
|
112
|
+
{ verifyHuman = fetch } = {},
|
|
113
|
+
) {
|
|
114
|
+
const path = url.pathname;
|
|
115
|
+
if (
|
|
116
|
+
path === "/purchases/keys.json"
|
|
117
|
+
) {
|
|
118
|
+
if (request.method !== "GET") return fail(405, "GET only");
|
|
119
|
+
if (!env.PURCHASE_SIGNING_KEYS)
|
|
120
|
+
return fail(404, "Purchase signing keys are not initialized");
|
|
121
|
+
try {
|
|
122
|
+
return new Response(JSON.stringify({ keys: await publicKeys(env) }), {
|
|
123
|
+
headers: {
|
|
124
|
+
"content-type": "application/jwk-set+json",
|
|
125
|
+
"access-control-allow-origin": "*",
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
} catch {
|
|
129
|
+
return fail(503, "Configure purchase signing keys before selling");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (!path.startsWith("/api/purchases/") && !path.startsWith("/purchases/"))
|
|
133
|
+
return null;
|
|
134
|
+
if (!cat.shop && request.method === 'GET' && path.startsWith('/purchases/') && !/^\/purchases\/ord_[A-Za-z0-9]{20}$/.test(path)) return null;
|
|
135
|
+
if (!cat.shop) return fail(503, "The seller has not configured shop.json; no payment is available");
|
|
136
|
+
if (env.HOMIE_PREVIEW === "1" || !env.DB)
|
|
137
|
+
return fail(503, "Studio purchases need the deployed database");
|
|
138
|
+
try {
|
|
139
|
+
if (!env.PURCHASE_RATE_LIMITER)
|
|
140
|
+
return fail(
|
|
141
|
+
503,
|
|
142
|
+
"Deploy the Cloudflare purchase rate limiting binding before selling",
|
|
143
|
+
);
|
|
144
|
+
if (
|
|
145
|
+
!(
|
|
146
|
+
await env.PURCHASE_RATE_LIMITER.limit({
|
|
147
|
+
key: `${request.headers.get("cf-connecting-ip") ?? "unknown"}:${request.method}`,
|
|
148
|
+
})
|
|
149
|
+
).success
|
|
150
|
+
)
|
|
151
|
+
return fail(429, "Too many purchase requests. Try again shortly");
|
|
152
|
+
if (path === "/api/purchases/mcp") {
|
|
153
|
+
const { purchaseMcp } = await import("./purchase-mcp.mjs");
|
|
154
|
+
return purchaseMcp(request, env, url, cat);
|
|
155
|
+
}
|
|
156
|
+
if (path === "/api/purchases/resource" || path.startsWith("/api/purchases/resource/")) {
|
|
157
|
+
const { machineResource } = await import("./purchase-machine.mjs");
|
|
158
|
+
return await machineResource(request, env, url, cat);
|
|
159
|
+
}
|
|
160
|
+
if (path === "/api/purchases/portal-login" && request.method === "GET") {
|
|
161
|
+
const login = cat.shop?.purchasesPortalLogin;
|
|
162
|
+
return /^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(
|
|
163
|
+
login ?? "",
|
|
164
|
+
)
|
|
165
|
+
? json({
|
|
166
|
+
ok: true,
|
|
167
|
+
url: login,
|
|
168
|
+
message:
|
|
169
|
+
"Stripe verifies your email. Obtain your receipt and manage billing there; give the receipt and a new claimHash to the seller to restore downloads.",
|
|
170
|
+
})
|
|
171
|
+
: fail(
|
|
172
|
+
503,
|
|
173
|
+
"Ask the seller to enable the Stripe customer portal login link; receipt-based seller reissue remains available",
|
|
174
|
+
);
|
|
175
|
+
}
|
|
176
|
+
if (path === "/api/purchases/intent" && request.method === "POST") {
|
|
177
|
+
return await createHostedIntent(request, env, url, cat);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const review = /^\/purchases\/(ord_[A-Za-z0-9]{20})$/.exec(path);
|
|
181
|
+
if (review) {
|
|
182
|
+
return await hostedPurchasePage(request, env, url, cat, review, verifyHuman);
|
|
183
|
+
}
|
|
184
|
+
if (path === "/api/purchases/verify" && request.method === "POST") {
|
|
185
|
+
return await verifyPurchaseEvidence(request, env, url);
|
|
186
|
+
}
|
|
187
|
+
if (
|
|
188
|
+
[
|
|
189
|
+
"/api/purchases/grant",
|
|
190
|
+
"/api/purchases/portal",
|
|
191
|
+
"/api/purchases/recover",
|
|
192
|
+
"/api/purchases/refund",
|
|
193
|
+
].includes(path) &&
|
|
194
|
+
request.method === "POST"
|
|
195
|
+
) {
|
|
196
|
+
return await claimedPurchase(request, env, url, path);
|
|
197
|
+
}
|
|
198
|
+
return fail(404, "No such studio purchase route");
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (/no such table/.test(error.message))
|
|
201
|
+
return fail(503, "Deploy to apply the purchase migration");
|
|
202
|
+
if (/Configure purchase signing/.test(error.message))
|
|
203
|
+
return fail(503, error.message);
|
|
204
|
+
if (error.status === 403 || error.status === 401)
|
|
205
|
+
return fail(
|
|
206
|
+
503,
|
|
207
|
+
"Reconnect the shop with Invoices, Subscriptions and Customer portal permissions",
|
|
208
|
+
);
|
|
209
|
+
if (
|
|
210
|
+
error instanceof SyntaxError ||
|
|
211
|
+
/Send JSON|Request too large/.test(error.message)
|
|
212
|
+
)
|
|
213
|
+
return fail(400, error.message);
|
|
214
|
+
return fail(
|
|
215
|
+
503,
|
|
216
|
+
"Payment or storage temporarily unavailable. Retry this purchase; do not pay again.",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async function createHostedIntent(request, env, url, cat) {
|
|
222
|
+
const b = await bodyOf(request);
|
|
223
|
+
if (
|
|
224
|
+
!hasResourceKind(b.kind) || !safeId(b.resource) ||
|
|
225
|
+
!versionOf(b.version) ||
|
|
226
|
+
!validBuyer(b.buyer) ||
|
|
227
|
+
!validBuyer(b.claimHash)
|
|
228
|
+
)
|
|
229
|
+
return fail(
|
|
230
|
+
400,
|
|
231
|
+
"Invalid resource, version, studio identity or purchase claim",
|
|
232
|
+
);
|
|
233
|
+
const listed = await listedRelease(env, url.origin, b.kind, b.resource, b.version);
|
|
234
|
+
const p = listed?.resource;
|
|
235
|
+
const offer = listed?.offer;
|
|
236
|
+
if (
|
|
237
|
+
(cat.shop?.purchasesTill ?? cat.shop?.till) === "stripe-managed" &&
|
|
238
|
+
!offer?.taxCode
|
|
239
|
+
)
|
|
240
|
+
return fail(
|
|
241
|
+
400,
|
|
242
|
+
"Managed Payments requires an eligible product tax code",
|
|
243
|
+
);
|
|
244
|
+
if (!p?.sale || resourceKind(b.kind).validate({ ...p, sale: offer }).length)
|
|
245
|
+
return fail(404, "This paid offer is not available");
|
|
246
|
+
if (
|
|
247
|
+
b.quote !== (await resourceKind(b.kind).quote(p, b.quantity ?? 1, b.game ?? null, offer)) && b.offerVersion !== (await offerVersion({ kind: b.kind, id: p.id, release: p.version, manifest: await manifestHash(p) }, offer)).version
|
|
248
|
+
)
|
|
249
|
+
return fail(
|
|
250
|
+
409,
|
|
251
|
+
"The quote changed. Read the price and terms and ask again",
|
|
252
|
+
);
|
|
253
|
+
const quantity = b.quantity ?? 1;
|
|
254
|
+
const selectionErrors = resourceKind(b.kind).validate({ ...p, sale: offer }, { quantity, game: b.game ?? null });
|
|
255
|
+
if (selectionErrors.length) return fail(400, selectionErrors.join('; '));
|
|
256
|
+
const readiness = await hostedReadiness(env, cat, offer.billing !== 'one-time');
|
|
257
|
+
if (!readiness.ready) return fail(503, readiness.missing.join('; '));
|
|
258
|
+
const id = newOrderId();
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
const intent = {
|
|
261
|
+
id,
|
|
262
|
+
resource_kind: b.kind,
|
|
263
|
+
resource_id: p.id,
|
|
264
|
+
item: `${b.kind}:${p.id}`,
|
|
265
|
+
version: p.version,
|
|
266
|
+
buyer: b.buyer,
|
|
267
|
+
buyer_label: String(b.buyerName ?? "Buyer")
|
|
268
|
+
.replace(/[\x00-\x1f]/g, " ")
|
|
269
|
+
.slice(0, 80),
|
|
270
|
+
claim_hash: b.claimHash,
|
|
271
|
+
game: b.game ?? null,
|
|
272
|
+
quantity,
|
|
273
|
+
manifest_hash: await manifestHash(p),
|
|
274
|
+
amount: offer.amount * quantity,
|
|
275
|
+
currency: offer.currency,
|
|
276
|
+
offerHash: await digest(canonicalJson(offer)),
|
|
277
|
+
till: cat.shop.purchasesTill ?? cat.shop.till,
|
|
278
|
+
mode: shopMode(env),
|
|
279
|
+
status: "started",
|
|
280
|
+
created_at: now,
|
|
281
|
+
};
|
|
282
|
+
const key = await signingKey(env);
|
|
283
|
+
const { SignJWT } = await import("jose");
|
|
284
|
+
const token = await new SignJWT(intent)
|
|
285
|
+
.setProtectedHeader({ alg: "Ed25519", typ: "homie-purchase-intent+jwt" })
|
|
286
|
+
.setIssuer(url.origin)
|
|
287
|
+
.setAudience("homie-purchase-checkout")
|
|
288
|
+
.setExpirationTime(
|
|
289
|
+
Math.floor(now / 1000) + (offer.intentMinutes ?? 30) * 60,
|
|
290
|
+
)
|
|
291
|
+
.sign(key.privateKey);
|
|
292
|
+
return json({
|
|
293
|
+
ok: true,
|
|
294
|
+
order: id,
|
|
295
|
+
url: `${url.origin}/purchases/${id}?intent=${token}`,
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
async function hostedPurchasePage(request, env, url, cat, review, verifyHuman) {
|
|
300
|
+
let o = await byId(env, review[1]);
|
|
301
|
+
if (!o) {
|
|
302
|
+
try {
|
|
303
|
+
const { jwtVerify, importJWK } = await import("jose");
|
|
304
|
+
const { payload } = await jwtVerify(
|
|
305
|
+
url.searchParams.get("intent"),
|
|
306
|
+
await importJWK((await signingKey(env)).publicJwk, "Ed25519"),
|
|
307
|
+
{
|
|
308
|
+
algorithms: ["Ed25519"],
|
|
309
|
+
typ: "homie-purchase-intent+jwt",
|
|
310
|
+
issuer: url.origin,
|
|
311
|
+
audience: "homie-purchase-checkout",
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
if (payload.id !== review[1])
|
|
315
|
+
return fail(403, "Intent identifies another purchase");
|
|
316
|
+
const listed = await listedRelease(
|
|
317
|
+
env,
|
|
318
|
+
url.origin,
|
|
319
|
+
payload.resource_kind,
|
|
320
|
+
payload.resource_id,
|
|
321
|
+
payload.version,
|
|
322
|
+
);
|
|
323
|
+
const p = listed?.resource;
|
|
324
|
+
if (
|
|
325
|
+
!p ||
|
|
326
|
+
(await manifestHash(p)) !== payload.manifest_hash ||
|
|
327
|
+
(await digest(canonicalJson(listed.offer))) !== payload.offerHash
|
|
328
|
+
)
|
|
329
|
+
return fail(
|
|
330
|
+
410,
|
|
331
|
+
"This offer changed or was withdrawn. Ask for a new quote",
|
|
332
|
+
);
|
|
333
|
+
o = {
|
|
334
|
+
...payload,
|
|
335
|
+
offer: JSON.stringify(listed.offer),
|
|
336
|
+
manifest: JSON.stringify(p),
|
|
337
|
+
};
|
|
338
|
+
} catch {
|
|
339
|
+
return fail(410, "Purchase intent expired. Ask for a fresh quote");
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
const p = JSON.parse(o.manifest);
|
|
343
|
+
if (request.method === "POST") {
|
|
344
|
+
if (
|
|
345
|
+
(await orderState(env, o.id))
|
|
346
|
+
?.transport === "machine"
|
|
347
|
+
)
|
|
348
|
+
return fail(
|
|
349
|
+
409,
|
|
350
|
+
"This order uses a wallet payment; retry that exchange instead of Checkout",
|
|
351
|
+
);
|
|
352
|
+
if (request.headers.get("origin") !== url.origin)
|
|
353
|
+
return fail(403, "Open this studio purchase page first");
|
|
354
|
+
if (o.status !== "started")
|
|
355
|
+
return fail(
|
|
356
|
+
409,
|
|
357
|
+
`Purchase is ${o.status}. Return to your assistant to retrieve it`,
|
|
358
|
+
);
|
|
359
|
+
if (o.mode !== shopMode(env))
|
|
360
|
+
return fail(
|
|
361
|
+
409,
|
|
362
|
+
"The shop changed payment mode. Ask for a fresh quote",
|
|
363
|
+
);
|
|
364
|
+
const current = await listedRelease(env, url.origin, o.resource_kind, p.id, p.version);
|
|
365
|
+
if (!current || JSON.stringify(current.offer) !== o.offer)
|
|
366
|
+
return fail(410, "This offer was withdrawn");
|
|
367
|
+
if (
|
|
368
|
+
o.created_at <
|
|
369
|
+
Date.now() - (JSON.parse(o.offer).intentMinutes ?? 30) * 60_000
|
|
370
|
+
)
|
|
371
|
+
return fail(
|
|
372
|
+
410,
|
|
373
|
+
"This purchase intent expired. Ask for a fresh quote",
|
|
374
|
+
);
|
|
375
|
+
const readiness = await hostedReadiness(env, cat, JSON.parse(o.offer).billing !== 'one-time');
|
|
376
|
+
if (!readiness.ready) return fail(503, readiness.missing.join('; '));
|
|
377
|
+
const formText = request.body ? await limitedText(request) : "";
|
|
378
|
+
const form = new URLSearchParams(formText);
|
|
379
|
+
if (env.TURNSTILE_SECRET) {
|
|
380
|
+
const checked = await verifyHuman(
|
|
381
|
+
"https://challenges.cloudflare.com/turnstile/v0/siteverify",
|
|
382
|
+
{
|
|
383
|
+
method: "POST",
|
|
384
|
+
body: new URLSearchParams({
|
|
385
|
+
secret: env.TURNSTILE_SECRET,
|
|
386
|
+
response: form.get("cf-turnstile-response") ?? "",
|
|
387
|
+
remoteip: request.headers.get("cf-connecting-ip") ?? "",
|
|
388
|
+
}),
|
|
389
|
+
},
|
|
390
|
+
);
|
|
391
|
+
const challenge = await checked.json();
|
|
392
|
+
if (
|
|
393
|
+
!challenge.success ||
|
|
394
|
+
challenge.hostname !== url.hostname ||
|
|
395
|
+
challenge.action !== "purchase-checkout"
|
|
396
|
+
)
|
|
397
|
+
return fail(403, "Complete the human check");
|
|
398
|
+
}
|
|
399
|
+
if (!(await byId(env, o.id))) await createPurchase(env, o);
|
|
400
|
+
o = await byId(env, o.id);
|
|
401
|
+
if (!hasStripeKey(env)) {
|
|
402
|
+
const link = purchaseLink(env, o);
|
|
403
|
+
if (!link) return fail(503, 'Run shop connect to sync this offer to Stripe Payment Links');
|
|
404
|
+
if (!(await resourceAvailable(env, o.resource_kind, p))) return fail(503, 'Paid files unavailable; no payment requested');
|
|
405
|
+
const checkout = new URL(link.url);
|
|
406
|
+
checkout.searchParams.set('client_reference_id', o.id);
|
|
407
|
+
await env.DB.prepare("INSERT OR IGNORE INTO meta (key,value) VALUES (?1,?2)").bind(`purchase-link:${o.id}`, JSON.stringify(link)).run();
|
|
408
|
+
return Response.redirect(checkout, 303);
|
|
409
|
+
}
|
|
410
|
+
let checkout = o.checkout_url;
|
|
411
|
+
if (!checkout) {
|
|
412
|
+
if (!(await resourceAvailable(env, o.resource_kind, p)))
|
|
413
|
+
return fail(503, "Paid files unavailable; no payment requested");
|
|
414
|
+
const s = await createCheckoutSession(
|
|
415
|
+
env,
|
|
416
|
+
purchaseSessionParams(o, url.origin, o.till),
|
|
417
|
+
{ idempotencyKey: `purchase-${o.id}` },
|
|
418
|
+
);
|
|
419
|
+
if (!s?.id || !s?.url?.startsWith("https://"))
|
|
420
|
+
return fail(502, "Stripe returned no checkout page");
|
|
421
|
+
await env.DB.batch([
|
|
422
|
+
orderFact(env, o.id, 'details', {session: s.id}),
|
|
423
|
+
orderFact(env, o.id, 'details', {checkout_url: s.url}),
|
|
424
|
+
]);
|
|
425
|
+
checkout = s.url;
|
|
426
|
+
}
|
|
427
|
+
return Response.redirect(checkout, 303);
|
|
428
|
+
}
|
|
429
|
+
if (request.method !== "GET") return fail(405, "GET or POST only");
|
|
430
|
+
const s = o.offer ? JSON.parse(o.offer) : p.sale;
|
|
431
|
+
return new Response(
|
|
432
|
+
`<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>Buy ${esc(p.name)}</title><main><h1>${esc(p.name)} ${esc(p.version)}</h1><p>${esc(cat.studio?.name ?? "Studio")} · ${esc(priceWords(s, o.quantity))} · ${esc(o.mode)} mode</p><p>Licence for studio ${esc(o.buyer_label)}${o.game ? `, game ${esc(o.game)}` : ""}. ${o.quantity} unit(s).</p><p>${esc(p.license)} · source ${s.source ? "included" : "not included"} · updates: ${esc(s.updates)} · commercial use: ${s.commercialUse ? "yes" : "no"} · transferable: ${s.transferable ? "yes" : "no"}</p><p>${esc(s.refund)}</p><p>After a full refund: ${esc(s.onRefund)} existing-use rights. After subscription expiry: ${esc(s.onExpiry)} existing-use rights.</p><p><a href="${esc(resourceKind(o.resource_kind).termsUrl(p))}">Read the full licence</a></p>${o.status === "started" ? `<form method="post">${env.TURNSTILE_SECRET ? `<script src="https://challenges.cloudflare.com/turnstile/v0/api.js" async defer></script><div class="cf-turnstile" data-sitekey="${esc(env.TURNSTILE_SITE_KEY ?? "")}" data-action="purchase-checkout"></div>` : ""}<p>By continuing you accept the licence for this studio and the price${s.billing !== "one-time" ? ", including recurring billing until cancelled in Stripe" : ""}. Tax and the final total appear on Stripe before you pay.</p><button>Accept and continue to Stripe · ${esc(priceWords(s, o.quantity))}</button></form>` : `<p>Purchase: ${esc(o.status)}. Return to your assistant to retrieve the files. Manage or cancel a subscription with the purchase's Stripe portal link from your assistant.</p>`}</main></html>`,
|
|
433
|
+
{
|
|
434
|
+
headers: {
|
|
435
|
+
"content-type": "text/html; charset=utf-8",
|
|
436
|
+
"cache-control": "no-store, private",
|
|
437
|
+
"referrer-policy": "same-origin",
|
|
438
|
+
"content-security-policy":
|
|
439
|
+
"default-src 'none'; script-src https://challenges.cloudflare.com; frame-src https://challenges.cloudflare.com; style-src 'unsafe-inline'; form-action 'self' https://checkout.stripe.com https://buy.stripe.com; frame-ancestors 'none'; base-uri 'none'",
|
|
440
|
+
},
|
|
441
|
+
},
|
|
442
|
+
);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
async function verifyPurchaseEvidence(request, env, url) {
|
|
446
|
+
const b = await bodyOf(request);
|
|
447
|
+
if (!/^https:\/\/[^/]+$/.test(b.audience ?? ""))
|
|
448
|
+
return fail(400, "Supply the hub HTTPS origin");
|
|
449
|
+
let proof;
|
|
450
|
+
for (const jwk of await publicKeys(env)) {
|
|
451
|
+
try {
|
|
452
|
+
proof = await verifyGrant(b.proof, jwk, {
|
|
453
|
+
issuer: url.origin,
|
|
454
|
+
proof: true,
|
|
455
|
+
audience: b.audience,
|
|
456
|
+
});
|
|
457
|
+
break;
|
|
458
|
+
} catch {
|
|
459
|
+
/* try another retained public key */
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!proof) return fail(401, "Invalid purchase evidence");
|
|
463
|
+
let o = await byId(env, proof.jti);
|
|
464
|
+
if (o && o.mode === shopMode(env)) {
|
|
465
|
+
await reconcileOrder(env, o);
|
|
466
|
+
o = await byId(env, o.id);
|
|
467
|
+
}
|
|
468
|
+
return json({
|
|
469
|
+
ok: true,
|
|
470
|
+
current: Boolean(
|
|
471
|
+
o &&
|
|
472
|
+
(await deliveryAllowed(env, o)) &&
|
|
473
|
+
active(o) &&
|
|
474
|
+
o.buyer === proof.sub && o.resource_kind === proof.kind && o.resource_id === proof.resource &&
|
|
475
|
+
proof.mode === o.mode &&
|
|
476
|
+
o.mode === "live" &&
|
|
477
|
+
shopMode(env) === "live",
|
|
478
|
+
),
|
|
479
|
+
order: proof.jti,
|
|
480
|
+
resource: proof.resource,
|
|
481
|
+
kind: proof.kind,
|
|
482
|
+
buyer: proof.sub,
|
|
483
|
+
checkedAt: new Date().toISOString(),
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
async function claimedPurchase(request, env, url, path) {
|
|
488
|
+
const claim = bearer(request);
|
|
489
|
+
if (!/^[a-f0-9]{64}$/.test(claim ?? ""))
|
|
490
|
+
return fail(401, "Purchase claim required");
|
|
491
|
+
let o = await byClaim(env, await digest(claim));
|
|
492
|
+
if (!o) return fail(401, "Purchase claim unknown");
|
|
493
|
+
const b = await bodyOf(request);
|
|
494
|
+
if (o.mode !== shopMode(env))
|
|
495
|
+
return fail(403, "Purchase belongs to another payment mode");
|
|
496
|
+
if (
|
|
497
|
+
o.status === "started" &&
|
|
498
|
+
!o.session &&
|
|
499
|
+
(await orderState(env, o.id))?.transport !==
|
|
500
|
+
"machine" &&
|
|
501
|
+
o.created_at <
|
|
502
|
+
Date.now() -
|
|
503
|
+
(JSON.parse(o.offer ?? o.manifest).intentMinutes ??
|
|
504
|
+
termsOf(o).intentMinutes ??
|
|
505
|
+
30) *
|
|
506
|
+
60_000
|
|
507
|
+
) {
|
|
508
|
+
await orderFact(env, o.id, 'expired', {})
|
|
509
|
+
.run();
|
|
510
|
+
o = await byId(env, o.id);
|
|
511
|
+
}
|
|
512
|
+
if (path.endsWith("/refund")) {
|
|
513
|
+
if (o.subscription) {
|
|
514
|
+
await reconcileSubscription(env, o);
|
|
515
|
+
o = await byId(env, o.id);
|
|
516
|
+
}
|
|
517
|
+
const days = termsOf(o).refundWindowDays;
|
|
518
|
+
let paidAt = Number(o.paid_at);
|
|
519
|
+
if (o.subscription && o.payment) {
|
|
520
|
+
const paid = await env.DB.prepare(
|
|
521
|
+
"SELECT invoice FROM purchase_payments WHERE payment = ?1",
|
|
522
|
+
)
|
|
523
|
+
.bind(o.payment)
|
|
524
|
+
.first();
|
|
525
|
+
if (paid?.invoice) {
|
|
526
|
+
const invoice = await stripeCall(
|
|
527
|
+
env,
|
|
528
|
+
"GET",
|
|
529
|
+
`/v1/invoices/${encodeURIComponent(paid.invoice)}`,
|
|
530
|
+
);
|
|
531
|
+
paidAt =
|
|
532
|
+
Number(
|
|
533
|
+
invoice.status_transitions?.paid_at ?? invoice.created ?? 0,
|
|
534
|
+
) * 1000;
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
if (o.paid_at && !o.payment) return fail(409, 'Stripe has not recorded this chain payment. Contact the seller for a direct refund to the original payer.');
|
|
538
|
+
if (!o.paid_at) return fail(409, "This order is not paid; there is no payment to refund");
|
|
539
|
+
if (
|
|
540
|
+
!Number.isFinite(days) ||
|
|
541
|
+
days <= 0 ||
|
|
542
|
+
!paidAt ||
|
|
543
|
+
Date.now() > paidAt + days * 86400000
|
|
544
|
+
)
|
|
545
|
+
return fail(
|
|
546
|
+
403,
|
|
547
|
+
"This purchase is outside the seller self-service refund window; contact the seller with your receipt",
|
|
548
|
+
);
|
|
549
|
+
const { refundOrder } = await import("./shop.mjs");
|
|
550
|
+
return json(await refundOrder(env, o, { by: "buyer" }));
|
|
551
|
+
}
|
|
552
|
+
if (path.endsWith("/recover"))
|
|
553
|
+
return json({
|
|
554
|
+
ok: true,
|
|
555
|
+
buyer: o.buyer,
|
|
556
|
+
quantity: o.quantity,
|
|
557
|
+
game: o.game,
|
|
558
|
+
manifest: JSON.parse(o.manifest),
|
|
559
|
+
order: o.id,
|
|
560
|
+
});
|
|
561
|
+
if (path.endsWith("/portal")) {
|
|
562
|
+
if (!o.customer || o.mode !== shopMode(env))
|
|
563
|
+
return fail(409, "No Stripe customer in the current payment mode");
|
|
564
|
+
if (o.till === "stripe-managed")
|
|
565
|
+
return json({ ok: true, url: "https://link.com" });
|
|
566
|
+
if (!hasStripeKey(env)) return json({ok:false, message:'Use the seller’s Stripe customer portal login link or ask the seller to manage this subscription in Stripe.'});
|
|
567
|
+
const portal = await stripeCall(
|
|
568
|
+
env,
|
|
569
|
+
"POST",
|
|
570
|
+
"/v1/billing_portal/sessions",
|
|
571
|
+
{
|
|
572
|
+
customer: o.customer,
|
|
573
|
+
return_url: `${url.origin}/purchases/${o.id}`,
|
|
574
|
+
},
|
|
575
|
+
);
|
|
576
|
+
return json({ ok: true, url: portal.url });
|
|
577
|
+
}
|
|
578
|
+
if (
|
|
579
|
+
hasStripeKey(env) && o.session &&
|
|
580
|
+
o.status === "started" &&
|
|
581
|
+
o.mode === shopMode(env)
|
|
582
|
+
) {
|
|
583
|
+
await reconcilePurchaseSession(
|
|
584
|
+
env,
|
|
585
|
+
await retrieveCheckoutSession(env, o.session),
|
|
586
|
+
);
|
|
587
|
+
o = await byId(env, o.id);
|
|
588
|
+
}
|
|
589
|
+
if (o.mode === shopMode(env)) {
|
|
590
|
+
await reconcileOrder(env, o);
|
|
591
|
+
o = await byId(env, o.id);
|
|
592
|
+
}
|
|
593
|
+
if (!(await deliveryAllowed(env, o)))
|
|
594
|
+
return fail(
|
|
595
|
+
403,
|
|
596
|
+
"Test files require owner approval for this test order; test orders never work in live mode",
|
|
597
|
+
);
|
|
598
|
+
if (!active(o))
|
|
599
|
+
return json(
|
|
600
|
+
{
|
|
601
|
+
ok: false,
|
|
602
|
+
status: o.status,
|
|
603
|
+
paidUntil: o.paid_until,
|
|
604
|
+
message: `Purchase is ${o.status}; downloads are not active`,
|
|
605
|
+
onRefund: termsOf(o).onRefund,
|
|
606
|
+
onExpiry: termsOf(o).onExpiry,
|
|
607
|
+
},
|
|
608
|
+
402,
|
|
609
|
+
);
|
|
610
|
+
return grantPurchase(env, url.origin, o, b);
|
|
611
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
export const SETTLEMENT_SCHEMA = `CREATE TABLE IF NOT EXISTS purchase_settlements (
|
|
2
|
+
id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES purchase_orders(id), mode TEXT NOT NULL,
|
|
3
|
+
network TEXT NOT NULL, payload TEXT NOT NULL, reference TEXT, state TEXT NOT NULL,
|
|
4
|
+
error TEXT, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL
|
|
5
|
+
); CREATE INDEX IF NOT EXISTS purchase_settlements_pending ON purchase_settlements(mode,state,updated_at);`;
|
|
6
|
+
export const PURCHASE_MIGRATION_FILE = "0015_purchases.sql";
|
|
7
|
+
export const PURCHASE_MIGRATION = `-- One immutable quote and one lifecycle record per purchase, separate from player orders.
|
|
8
|
+
CREATE TABLE IF NOT EXISTS purchase_orders (
|
|
9
|
+
id TEXT PRIMARY KEY, resource_kind TEXT NOT NULL, resource_id TEXT NOT NULL,
|
|
10
|
+
item TEXT NOT NULL, game TEXT, amount INTEGER NOT NULL, currency TEXT NOT NULL,
|
|
11
|
+
tax INTEGER, total INTEGER, till TEXT NOT NULL, mode TEXT NOT NULL, status TEXT NOT NULL,
|
|
12
|
+
session TEXT UNIQUE, payment TEXT, refund TEXT, dispute TEXT, note TEXT,
|
|
13
|
+
created_at INTEGER NOT NULL, paid_at INTEGER, refunded_at INTEGER, updated_at INTEGER NOT NULL,
|
|
14
|
+
claim_hash TEXT NOT NULL UNIQUE, buyer TEXT NOT NULL, buyer_label TEXT NOT NULL,
|
|
15
|
+
quantity INTEGER NOT NULL, manifest TEXT NOT NULL, manifest_hash TEXT NOT NULL,
|
|
16
|
+
customer TEXT, subscription TEXT UNIQUE, paid_until INTEGER, checkout_url TEXT,
|
|
17
|
+
offer_version TEXT NOT NULL, transport TEXT NOT NULL, fulfilled_at INTEGER,
|
|
18
|
+
accepted_at INTEGER NOT NULL, terms_hash TEXT NOT NULL, quote_hash TEXT NOT NULL,
|
|
19
|
+
offer TEXT NOT NULL, checkout_buyer TEXT NOT NULL
|
|
20
|
+
);
|
|
21
|
+
CREATE INDEX IF NOT EXISTS purchase_orders_status ON purchase_orders (status,created_at);
|
|
22
|
+
CREATE INDEX IF NOT EXISTS purchase_orders_payment ON purchase_orders (payment);
|
|
23
|
+
CREATE TABLE IF NOT EXISTS purchase_payments (
|
|
24
|
+
payment TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES purchase_orders(id), invoice TEXT
|
|
25
|
+
);
|
|
26
|
+
`;
|
|
27
|
+
export const PURCHASE_STATE_FILE = "0016_purchase_facts.sql";
|
|
28
|
+
export const PURCHASE_STATE = `-- Fail before making changes if the prerequisite migration is missing.
|
|
29
|
+
SELECT 1 FROM purchase_orders LIMIT 0;
|
|
30
|
+
${SETTLEMENT_SCHEMA}
|
|
31
|
+
|
|
32
|
+
CREATE TABLE IF NOT EXISTS purchase_manual_refunds (transaction_hash TEXT PRIMARY KEY, order_id TEXT NOT NULL UNIQUE REFERENCES purchase_orders(id));
|
|
33
|
+
CREATE TABLE IF NOT EXISTS purchase_offers (version TEXT PRIMARY KEY, value TEXT NOT NULL);
|
|
34
|
+
CREATE TABLE IF NOT EXISTS purchase_receipts (order_id TEXT PRIMARY KEY REFERENCES purchase_orders(id) ON DELETE CASCADE, value TEXT, headers TEXT);
|
|
35
|
+
CREATE TABLE IF NOT EXISTS purchase_test_approvals (order_id TEXT PRIMARY KEY REFERENCES purchase_orders(id) ON DELETE CASCADE, value TEXT NOT NULL);
|
|
36
|
+
CREATE TABLE IF NOT EXISTS purchase_grants (order_id TEXT NOT NULL, version TEXT NOT NULL, manifest_hash TEXT NOT NULL, PRIMARY KEY(order_id,version));
|
|
37
|
+
CREATE TABLE IF NOT EXISTS resource_retirements (resource TEXT PRIMARY KEY, retired_at INTEGER NOT NULL);
|
|
38
|
+
CREATE TABLE IF NOT EXISTS resource_snapshots (hash TEXT PRIMARY KEY, manifest TEXT NOT NULL);
|
|
39
|
+
INSERT OR IGNORE INTO resource_snapshots (hash,manifest) SELECT manifest_hash,manifest FROM purchase_orders;
|
|
40
|
+
CREATE TABLE IF NOT EXISTS purchase_payment_facts (
|
|
41
|
+
payment TEXT NOT NULL, invoice TEXT NOT NULL DEFAULT '', order_id TEXT NOT NULL,
|
|
42
|
+
state TEXT NOT NULL DEFAULT 'paid', period_end INTEGER, dispute TEXT,
|
|
43
|
+
PRIMARY KEY(payment, invoice)
|
|
44
|
+
);
|
|
45
|
+
CREATE TABLE IF NOT EXISTS purchase_jobs (
|
|
46
|
+
id TEXT PRIMARY KEY, order_id TEXT NOT NULL REFERENCES purchase_orders(id), mode TEXT NOT NULL,
|
|
47
|
+
state TEXT NOT NULL, payload TEXT NOT NULL, payment TEXT, error TEXT,
|
|
48
|
+
attempts INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL
|
|
49
|
+
); CREATE INDEX IF NOT EXISTS purchase_jobs_pending ON purchase_jobs(mode,state,updated_at);
|
|
50
|
+
`;
|