@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,26 @@
|
|
|
1
|
+
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
2
|
+
import { listOffers } from './resource-kinds.mjs';
|
|
3
|
+
export async function purchaseDiscovery(env, origin, catalogue = {}) {
|
|
4
|
+
const { generateProxy } = await import('mppx/discovery');
|
|
5
|
+
const capabilities = await machineCapabilities(env);
|
|
6
|
+
const routes = [];
|
|
7
|
+
for (const resource of await listOffers(env,origin)) {
|
|
8
|
+
const offer = resource.offer;
|
|
9
|
+
const eligible = (catalogue.shop?.purchasesTill ?? catalogue.shop?.till) !== 'stripe-managed' && offer.amount > 0 && offer.billing === 'one-time' && offer.currency === 'usd' && !offer.automaticTax && offer.taxBehavior !== 'exclusive';
|
|
10
|
+
const offers = eligible ? Object.entries(capabilities.machine).filter(([, enabled]) => enabled).map(([method]) => ({ method: method === 'card' ? 'stripe' : method === 'base' ? 'evm' : method, intent: 'charge', amount: String(method === 'card' ? offer.amount : offer.amount * 10000), currency: 'usd' })) : [];
|
|
11
|
+
routes.push({ method: 'POST', path: `/api/purchases/resource/${resource.kind}/${resource.id}/${resource.version}`, summary: `Buy ${resource.name} ${resource.version}. Challenges expire after five minutes. A new purchase requires a fresh private claim.`, payment: offers.length ? { offers } : null,
|
|
12
|
+
requestBody: { required: true, content: { 'application/json': { schema: {
|
|
13
|
+
type: 'object', required: ['kind','resource','version','buyer','claim','offerVersion'],
|
|
14
|
+
properties: {
|
|
15
|
+
kind: {type:'string',const:resource.kind}, resource: {type:'string',const:resource.id}, version: {type:'string',const:resource.version},
|
|
16
|
+
buyer:{type:'string',pattern:'^[a-f0-9]{64}$',description:'Private random buyer identifier (32 bytes, hex).'},
|
|
17
|
+
claim:{type:'string',pattern:'^[a-f0-9]{64}$',description:'Private random secret for this purchase (32 bytes, hex). Keep it for retries and downloads.'},
|
|
18
|
+
offerVersion:{type:'string',const:resource.offerVersion},quantity:{type:'integer',minimum:1,default:1},game:{type:'string',description:'Game identifier required for a game-scoped offer.'},
|
|
19
|
+
},
|
|
20
|
+
} } } },
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
const doc=generateProxy({info:{title:'Studio purchases',version:'1'},routes});doc.servers=[{url:origin}];
|
|
24
|
+
doc.paths['/api/purchases/intent'] = { post: { summary: 'Create a hosted Checkout fallback; open the returned seller URL to accept terms and complete Turnstile.', requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['kind','resource','version','buyer','claimHash','offerVersion'], properties: { kind: {type:'string'}, resource: {type:'string'}, version: {type:'string'}, buyer: {type:'string',pattern:'^[a-f0-9]{64}$'}, claimHash: {type:'string', description:'SHA-256 of the private random claim, hex'}, offerVersion: {type:'string'}, quantity: {type:'integer',minimum:1}, game: {type:'string'} } } } } }, responses: { '200': {description:'Order and hosted checkout URL'}, '409': {description:'Offer changed'} } } };
|
|
25
|
+
return Response.json(doc,{headers:{'cache-control':'no-store'}});
|
|
26
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { hasStripeKey } from './shop-links.mjs';
|
|
2
|
+
import { purchaseLinks } from './purchase-links.mjs';
|
|
3
|
+
import { KEY_SHAPE, WEBHOOK_SECRET_SHAPE } from './stripe.mjs';
|
|
4
|
+
import { signingKey } from './purchase-keys.mjs';
|
|
5
|
+
import { recurringReady } from './purchase-store.mjs';
|
|
6
|
+
|
|
7
|
+
/** The hosted route and its office report use the same local configuration checks. */
|
|
8
|
+
export async function hostedReadiness(env, catalogue, recurring = false) {
|
|
9
|
+
const missing = [];
|
|
10
|
+
if (!env.DB || env.HOMIE_PREVIEW === '1') missing.push('Studio purchases need the deployed database');
|
|
11
|
+
if (!env.PURCHASE_RATE_LIMITER) missing.push('Deploy the purchase rate limiting binding');
|
|
12
|
+
try { await signingKey(env); } catch { missing.push('Create a valid purchase signing key'); }
|
|
13
|
+
if ((!hasStripeKey(env) && !purchaseLinks(env)) || !WEBHOOK_SECRET_SHAPE.test(env.STRIPE_WEBHOOK_SECRET ?? '') || !['stripe', 'stripe-managed'].includes(catalogue.shop?.purchasesTill ?? catalogue.shop?.till)) missing.push('Connect this studio shop to Stripe first');
|
|
14
|
+
if (recurring && hasStripeKey(env) && !(await recurringReady(env))) missing.push('Reconnect the shop to verify subscription permissions and webhook events before selling recurring resources');
|
|
15
|
+
return { ready: missing.length === 0, missing };
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/** The licence key ring is a Worker secret, independent of referral statements and D1. */
|
|
2
|
+
|
|
3
|
+
export async function purchaseKeys(env) {
|
|
4
|
+
let keys;
|
|
5
|
+
try { keys = JSON.parse(env.PURCHASE_SIGNING_KEYS); } catch { throw new Error('Configure purchase signing keys before selling'); }
|
|
6
|
+
if (!Array.isArray(keys) || !keys.length || !keys[0].d) throw new Error('Configure purchase signing keys before selling');
|
|
7
|
+
return keys;
|
|
8
|
+
}
|
|
9
|
+
export async function signingKey(env) {
|
|
10
|
+
const { importJWK } = await import('jose');
|
|
11
|
+
const [jwk] = await purchaseKeys(env);
|
|
12
|
+
return { privateKey: await importJWK(jwk, 'Ed25519'), publicJwk: { kty: jwk.kty, crv: jwk.crv, x: jwk.x } };
|
|
13
|
+
}
|
|
14
|
+
export async function publicKeys(env) {
|
|
15
|
+
const { calculateJwkThumbprint } = await import('jose');
|
|
16
|
+
return Promise.all((await purchaseKeys(env)).map(async ({ kty, crv, x }) => {
|
|
17
|
+
const jwk = { kty, crv, x };
|
|
18
|
+
return { ...jwk, kid: await calculateJwkThumbprint(jwk), alg: 'Ed25519', use: 'sig' };
|
|
19
|
+
}));
|
|
20
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/** Public Payment Link configuration, installed by the owner's existing CLI authorization. */
|
|
2
|
+
import { paymentLinkURL, linkConfig } from './shop-links.mjs';
|
|
3
|
+
import { canonicalJson } from './referrals.mjs';
|
|
4
|
+
export function purchaseLinks(env) {
|
|
5
|
+
try { const c = JSON.parse(env.PURCHASE_LINKS ?? 'null'); return c?.v === 1 && c.mode === linkConfig(env)?.mode && Array.isArray(c.offers) ? c : null; } catch { return null; }
|
|
6
|
+
}
|
|
7
|
+
export function purchaseLink(env, o) {
|
|
8
|
+
return purchaseLinks(env)?.offers.find((link) => (link.quantity ?? 1) === o.quantity && link.kind === o.resource_kind && link.resource === o.resource_id && link.version === JSON.parse(o.manifest).version && link.offer === canonicalJson(JSON.parse(o.offer)) && paymentLinkURL(link.url));
|
|
9
|
+
}
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
import { definitiveRefusal, blockingJobsSQL } from './payment-policy.mjs';
|
|
2
|
+
import { orderFact, offerVersion, orderState } from './purchase-core.mjs';
|
|
3
|
+
import { problem } from './purchase-problem.mjs';
|
|
4
|
+
import { saveRecording, recordProviderResult, recordingFailure, reconcileRecordings } from './payment-recovery.mjs';
|
|
5
|
+
import { machineCapabilities, notePaymentRefusal } from './payment-capabilities.mjs';
|
|
6
|
+
import { purchaseResponse } from './purchase-response.mjs';
|
|
7
|
+
/** One durable purchase, regardless of transport. No seller browser page is involved. */
|
|
8
|
+
import { Receipt } from 'mppx';
|
|
9
|
+
import { signingKey } from './purchase-keys.mjs';
|
|
10
|
+
import {
|
|
11
|
+
byClaim,
|
|
12
|
+
expireUnpaidMachineOrders,
|
|
13
|
+
byId,
|
|
14
|
+
createPurchase,
|
|
15
|
+
currentPurchaseOrder,
|
|
16
|
+
deliveryAllowed,
|
|
17
|
+
grantPurchase,
|
|
18
|
+
listedRelease,
|
|
19
|
+
limitedText,
|
|
20
|
+
resourceAvailable,
|
|
21
|
+
} from "./purchases.mjs";
|
|
22
|
+
import { paymentProtocol, stripeClient } from './machine-payments.mjs';
|
|
23
|
+
import { digest, manifestHash } from './purchase-crypto.mjs';
|
|
24
|
+
import { resourceKind, hasResourceKind } from './resource-kinds.mjs';
|
|
25
|
+
import { modeOf } from './stripe.mjs';
|
|
26
|
+
import { newOrderId } from './shop-store.mjs';
|
|
27
|
+
|
|
28
|
+
export async function machineResource(
|
|
29
|
+
request,
|
|
30
|
+
env,
|
|
31
|
+
url,
|
|
32
|
+
cat,
|
|
33
|
+
dependencies = {},
|
|
34
|
+
) {
|
|
35
|
+
const hostedFallback = (detail) => problem(409, detail, { checkout: { url: `${url.origin}/api/purchases/intent`, method: 'POST', description: 'Hosted Checkout fallback. Submit the published offerVersion, buyer and private claimHash.' } });
|
|
36
|
+
const mcp = Boolean(dependencies.mcpInput);
|
|
37
|
+
if (dependencies.dryRun && (mcp || request.headers.has('authorization') || request.headers.has('payment-signature'))) throw new Error('Dry exchanges cannot carry payment credentials');
|
|
38
|
+
if (request.method !== "POST")
|
|
39
|
+
return problem(405, "POST the resource, buyer, claim and approved quote");
|
|
40
|
+
const b = await machineInput(request, dependencies.mcpInput);
|
|
41
|
+
if (b instanceof Response) return b;
|
|
42
|
+
if (
|
|
43
|
+
!b ||
|
|
44
|
+
!/^[a-f0-9]{64}$/.test(b.claim ?? "") ||
|
|
45
|
+
!/^[a-f0-9]{64}$/.test(b.buyer ?? "")
|
|
46
|
+
)
|
|
47
|
+
return problem(400, "Supply a private random claim and buyer identity");
|
|
48
|
+
if (!hasResourceKind(b.kind)) return problem(400,"Name a supported resource kind");
|
|
49
|
+
const adapter = resourceKind(b.kind);
|
|
50
|
+
const quoteHash = adapter.quote;
|
|
51
|
+
const claimHash = await digest(b.claim);
|
|
52
|
+
const resourcePath = `/api/purchases/resource/${encodeURIComponent(b.kind)}/${encodeURIComponent(b.resource)}/${encodeURIComponent(b.version)}/${claimHash}`;
|
|
53
|
+
if (!mcp && url.pathname !== '/api/purchases/resource' && url.pathname !== resourcePath.slice(0, resourcePath.lastIndexOf('/')) && url.pathname !== resourcePath)
|
|
54
|
+
return problem(409, "Resource URL differs from this purchase");
|
|
55
|
+
if (!mcp) request = new Request(new URL(resourcePath, url.origin), request);
|
|
56
|
+
const approved = async (resource, quantity, game, offer) => b.quote === await quoteHash(resource, quantity, game, offer) || b.offerVersion === (await offerVersion({ kind: b.kind, id: resource.id, release: resource.version, manifest: await manifestHash(resource) }, offer)).version;
|
|
57
|
+
await expireUnpaidMachineOrders(env);
|
|
58
|
+
let o = await byClaim(env, claimHash);
|
|
59
|
+
if (!o) {
|
|
60
|
+
if ((cat.shop?.purchasesTill ?? cat.shop?.till) === 'stripe-managed') return hostedFallback('This seller uses Stripe Managed Payments.');
|
|
61
|
+
const listed = await listedRelease(env, url.origin, b.kind, b.resource, b.version);
|
|
62
|
+
if (!listed) return problem(404, "Offer unavailable");
|
|
63
|
+
const { resource, offer } = listed;
|
|
64
|
+
const quantity = b.quantity ?? 1;
|
|
65
|
+
const game = b.game ?? null;
|
|
66
|
+
if (offer.billing !== "one-time")
|
|
67
|
+
return hostedFallback("Recurring purchases use Stripe Checkout");
|
|
68
|
+
if (offer.amount === 0) return hostedFallback("A zero-price offer uses hosted Checkout.");
|
|
69
|
+
if (offer.currency !== "usd") return hostedFallback("This currency requires hosted Checkout; machine payments currently support USD.");
|
|
70
|
+
if (
|
|
71
|
+
offer.automaticTax ||
|
|
72
|
+
offer.taxBehavior === "exclusive"
|
|
73
|
+
)
|
|
74
|
+
return hostedFallback("This offer needs Checkout to calculate its final total");
|
|
75
|
+
const validation = adapter.validate({ ...resource, sale: offer }, { quantity, game });
|
|
76
|
+
if (validation.length) return problem(400, validation.join('; '));
|
|
77
|
+
if (!(await approved(resource, quantity, game, offer)))
|
|
78
|
+
return problem(409, "Price or terms changed; approve the fresh quote");
|
|
79
|
+
// Check readiness and bytes before an agent can spend anything.
|
|
80
|
+
if (!env.PURCHASE_SIGNING_KEYS || !(await machineCapabilities(env)).ready)
|
|
81
|
+
return hostedFallback("Seller machine payments are not configured. Use Hosted Checkout.");
|
|
82
|
+
await signingKey(env);
|
|
83
|
+
if (!(await resourceAvailable(env, b.kind, resource)))
|
|
84
|
+
return problem(503, "Paid files unavailable; no payment requested");
|
|
85
|
+
const pending = {
|
|
86
|
+
id: newOrderId(),
|
|
87
|
+
resource_kind: b.kind,
|
|
88
|
+
item: `${b.kind}:${resource.id}`,
|
|
89
|
+
game,
|
|
90
|
+
amount: quantity * offer.amount,
|
|
91
|
+
currency: offer.currency,
|
|
92
|
+
till: "stripe",
|
|
93
|
+
mode: modeOf(env.STRIPE_KEY),
|
|
94
|
+
claim_hash: claimHash,
|
|
95
|
+
buyer: b.buyer,
|
|
96
|
+
buyer_label: "Buyer",
|
|
97
|
+
quantity,
|
|
98
|
+
transport: "machine",
|
|
99
|
+
manifest: JSON.stringify(resource),
|
|
100
|
+
manifest_hash: await manifestHash(resource),
|
|
101
|
+
offer: JSON.stringify(offer),
|
|
102
|
+
};
|
|
103
|
+
await createPurchase(env, pending);
|
|
104
|
+
o = await byId(env, pending.id);
|
|
105
|
+
}
|
|
106
|
+
const hasCredential = request.headers.has('authorization') || request.headers.has('payment-signature') || Boolean(dependencies.extra?._meta);
|
|
107
|
+
await reconcileRecordings(env, o.id, { ...(dependencies.chainRpc ? { rpc: dependencies.chainRpc } : {}), facilitator: dependencies.facilitator });
|
|
108
|
+
o = await byId(env, o.id);
|
|
109
|
+
const pending = await env.DB.prepare(`SELECT 1 FROM purchase_jobs WHERE order_id = ?1 AND ${blockingJobsSQL} UNION ALL SELECT 1 FROM purchase_settlements WHERE order_id = ?1 AND state NOT IN ('complete','unpaid') LIMIT 1`).bind(o.id).first();
|
|
110
|
+
if (pending && !['paid','fulfilled','disputed'].includes(o.status)) return problem(503, 'Payment outcome is being reconciled. Retry this purchase; do not pay again.');
|
|
111
|
+
if (o.status === "started") {
|
|
112
|
+
const listed = await listedRelease(env, url.origin, b.kind, b.resource, b.version);
|
|
113
|
+
if (Date.now() >= o.created_at + 300000 || !listed ||
|
|
114
|
+
await quoteHash(listed.resource, o.quantity, o.game, listed.offer) !== await quoteHash(JSON.parse(o.manifest), o.quantity, o.game, JSON.parse(o.offer))) {
|
|
115
|
+
await orderFact(env, o.id, 'expired', {}).run();
|
|
116
|
+
return problem(409, "This five-minute purchase has expired or its offer changed. Start a new purchase with the current offer.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
const core = await orderState(env, o.id);
|
|
120
|
+
if (o.status === "started" && core?.transport !== "machine")
|
|
121
|
+
return problem(
|
|
122
|
+
409,
|
|
123
|
+
"This order uses Checkout; do not pay it through a wallet as well",
|
|
124
|
+
);
|
|
125
|
+
if (o.payment && hasCredential) o = await currentPurchaseOrder(env, o.id);
|
|
126
|
+
const resource = JSON.parse(o.manifest);
|
|
127
|
+
if (
|
|
128
|
+
o.resource_kind !== b.kind ||
|
|
129
|
+
o.buyer !== b.buyer ||
|
|
130
|
+
o.mode !== modeOf(env.STRIPE_KEY) ||
|
|
131
|
+
resource.id !== b.resource ||
|
|
132
|
+
resource.version !== b.version ||
|
|
133
|
+
!(await approved(resource, o.quantity, o.game, JSON.parse(o.offer)))
|
|
134
|
+
)
|
|
135
|
+
return problem(409, "Request differs from this purchase");
|
|
136
|
+
const response = async () => {
|
|
137
|
+
if (!(await deliveryAllowed(env, o)))
|
|
138
|
+
throw new Error(
|
|
139
|
+
"Test downloads need owner approval and never work in live mode",
|
|
140
|
+
);
|
|
141
|
+
const grant = await grantPurchase(env, url.origin, o, {
|
|
142
|
+
version: b.version,
|
|
143
|
+
});
|
|
144
|
+
if (!grant.ok) return grant;
|
|
145
|
+
const result = await grant.json();
|
|
146
|
+
return { ...result, order: o.id };
|
|
147
|
+
};
|
|
148
|
+
if (!dependencies.dryRun && o.mode === 'test' && !(await deliveryAllowed(env, o)))
|
|
149
|
+
return problem(403, `Test downloads need owner approval for order ${o.id}. No payment requested.`, { order: o.id });
|
|
150
|
+
const client = recordingClient(env, o, dependencies.client ?? stripeClient(env));
|
|
151
|
+
const receiptKey = o.id;
|
|
152
|
+
const stored = await env.DB.prepare("SELECT value FROM purchase_receipts WHERE order_id = ?1")
|
|
153
|
+
.bind(receiptKey)
|
|
154
|
+
.first();
|
|
155
|
+
o = await byId(env, o.id);
|
|
156
|
+
if (["paid", "fulfilled", "disputed"].includes(o.status)) {
|
|
157
|
+
const recorded = stored ? null : await env.DB.prepare("SELECT payload FROM purchase_jobs WHERE order_id = ?1 ORDER BY updated_at DESC LIMIT 1").bind(o.id).first();
|
|
158
|
+
const recording = recorded && JSON.parse(recorded.payload);
|
|
159
|
+
const challengeId = recording?.params?.metadata?.mpp_challenge_id;
|
|
160
|
+
if (!stored && !challengeId) return problem(409, "This purchase has no machine-payment challenge receipt. Retrieve the paid files using the purchase grant endpoint.");
|
|
161
|
+
const receipt = stored ? JSON.parse(stored.value) : {
|
|
162
|
+
method: recording?.params?.metadata?.mpp_method ?? "stripe", status: "success", reference: recording?.chain?.transaction_hash ?? o.payment,
|
|
163
|
+
timestamp: new Date(o.paid_at).toISOString(), challengeId,
|
|
164
|
+
};
|
|
165
|
+
const result = await response();
|
|
166
|
+
if (result instanceof Response) return result;
|
|
167
|
+
if (mcp) return machineMcpDelivery(result, dependencies, receipt);
|
|
168
|
+
const delivered = purchaseResponse(result, env);
|
|
169
|
+
if (!mcp) {
|
|
170
|
+
delivered.headers.set("Payment-Receipt", Receipt.serialize(receipt));
|
|
171
|
+
const headers = await env.DB.prepare(
|
|
172
|
+
"SELECT headers AS value FROM purchase_receipts WHERE order_id = ?1 AND headers IS NOT NULL",
|
|
173
|
+
)
|
|
174
|
+
.bind(o.id)
|
|
175
|
+
.first();
|
|
176
|
+
if (!headers && recording?.chain?.network === 'base') delivered.headers.set('payment-response', btoa(JSON.stringify({ success: true, transaction: recording.chain.transaction_hash, network: o.mode === 'live' ? 'eip155:8453' : 'eip155:84532' })));
|
|
177
|
+
if (headers)
|
|
178
|
+
for (const [key, value] of Object.entries(JSON.parse(headers.value)))
|
|
179
|
+
delivered.headers.set(key, value);
|
|
180
|
+
}
|
|
181
|
+
return mcp ? (await delivered.json()).result : delivered;
|
|
182
|
+
}
|
|
183
|
+
if (o.status !== "started")
|
|
184
|
+
return problem(402, `Purchase is ${o.status}; do not pay this order again`);
|
|
185
|
+
const readiness = await machineCapabilities(env);
|
|
186
|
+
if (!readiness.ready) return problem(503, Object.values(readiness.reasons).join(' '));
|
|
187
|
+
const protocol = await paymentProtocol(env, o, {
|
|
188
|
+
...dependencies,
|
|
189
|
+
client,
|
|
190
|
+
mcp,
|
|
191
|
+
realm: url.hostname,
|
|
192
|
+
capabilities: readiness,
|
|
193
|
+
});
|
|
194
|
+
protocol.onPaymentSuccess(async ({ receipt, challenge }) => {
|
|
195
|
+
receipt = { ...receipt, challengeId: challenge.id };
|
|
196
|
+
await env.DB.prepare(
|
|
197
|
+
"INSERT INTO purchase_receipts (order_id,value) VALUES (?1,?2) ON CONFLICT(order_id) DO UPDATE SET value = excluded.value",
|
|
198
|
+
)
|
|
199
|
+
.bind(receiptKey, JSON.stringify(receipt))
|
|
200
|
+
.run();
|
|
201
|
+
});
|
|
202
|
+
const paid = await protocol.charge({
|
|
203
|
+
expires: new Date(o.created_at + 300000).toISOString(),
|
|
204
|
+
amount: (o.amount / 100).toFixed(2),
|
|
205
|
+
description: `${resource.name} ${resource.version}`,
|
|
206
|
+
scope: o.id,
|
|
207
|
+
meta: { order: o.id, quote: await quoteHash(resource, o.quantity, o.game, JSON.parse(o.offer)) },
|
|
208
|
+
paymentIntentOptions: { metadata: { homie: "purchase-v1", order: o.id } },
|
|
209
|
+
})(mcp ? dependencies.extra : request);
|
|
210
|
+
if (paid.status === 402) {
|
|
211
|
+
const uncertain = await env.DB.prepare(`SELECT 1 FROM purchase_jobs WHERE order_id = ?1 AND ${blockingJobsSQL} UNION ALL SELECT 1 FROM purchase_settlements WHERE order_id = ?1 AND state NOT IN ('complete','unpaid') LIMIT 1`).bind(o.id).first();
|
|
212
|
+
if (uncertain) return problem(503, 'The payment outcome is being reconciled. Retry this purchase; do not pay again.');
|
|
213
|
+
if (mcp) throw paid.challenge;
|
|
214
|
+
return paid.challenge;
|
|
215
|
+
}
|
|
216
|
+
if (!mcp) {
|
|
217
|
+
const receiptResponse = paid.withReceipt(new Response());
|
|
218
|
+
const headers = Object.fromEntries(
|
|
219
|
+
["payment-receipt", "payment-response"]
|
|
220
|
+
.filter((key) => receiptResponse.headers.has(key))
|
|
221
|
+
.map((key) => [key, receiptResponse.headers.get(key)]),
|
|
222
|
+
);
|
|
223
|
+
await env.DB.prepare(
|
|
224
|
+
"INSERT INTO purchase_receipts (order_id,headers) VALUES (?1,?2) ON CONFLICT(order_id) DO UPDATE SET headers = excluded.headers",
|
|
225
|
+
)
|
|
226
|
+
.bind(o.id, JSON.stringify(headers))
|
|
227
|
+
.run();
|
|
228
|
+
}
|
|
229
|
+
o = await byId(env, o.id);
|
|
230
|
+
if (!["paid","fulfilled"].includes(o.status))
|
|
231
|
+
return problem(
|
|
232
|
+
503,
|
|
233
|
+
"Settlement is being recorded. Retry this order; do not pay again",
|
|
234
|
+
);
|
|
235
|
+
const result = await response();
|
|
236
|
+
if (result instanceof Response) return result;
|
|
237
|
+
if (mcp) return paid.withReceipt(machineMcpDelivery(result, dependencies));
|
|
238
|
+
return paid.withReceipt(purchaseResponse(result, env));
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function machineMcpDelivery(result, dependencies, receipt) {
|
|
242
|
+
dependencies.setDelivery?.(result);
|
|
243
|
+
return { content: [{ type: 'text', text: JSON.stringify(result) }], ...(receipt ? { _meta: { 'org.paymentauth/receipt': receipt } } : {}) };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function machineInput(request, mcpInput) {
|
|
247
|
+
let text;
|
|
248
|
+
try { text = await limitedText(request.clone()); } catch { return problem(413, 'Purchase request too large'); }
|
|
249
|
+
if (new TextEncoder().encode(text).length > 16384) return problem(413, 'Purchase request too large');
|
|
250
|
+
let input;
|
|
251
|
+
try { input = JSON.parse(text); } catch { return problem(400, 'Invalid JSON request'); }
|
|
252
|
+
if (!input || typeof input !== 'object') return problem(400, 'Expected a purchase request');
|
|
253
|
+
return mcpInput ?? input;
|
|
254
|
+
}
|
|
255
|
+
function recordingClient(env, o, providerClient) {
|
|
256
|
+
|
|
257
|
+
const create = providerClient.paymentIntents.create.bind(
|
|
258
|
+
providerClient.paymentIntents,
|
|
259
|
+
);
|
|
260
|
+
const client = Object.create(providerClient);
|
|
261
|
+
client.paymentIntents = Object.create(providerClient.paymentIntents);
|
|
262
|
+
client.paymentIntents.create = async (params, options) => {
|
|
263
|
+
params = { ...params, metadata: { ...params.metadata, homie: "purchase-v1", order: o.id } };
|
|
264
|
+
const id = await saveRecording(env, o, params, options);
|
|
265
|
+
try {
|
|
266
|
+
const saved = await env.DB.prepare('SELECT state,payload FROM purchase_jobs WHERE id = ?1').bind(id).first();
|
|
267
|
+
if (saved.state === 'manual_review') throw new Error('Stripe recording was refused; the seller must handle any refund manually.');
|
|
268
|
+
const original = JSON.parse(saved.payload);
|
|
269
|
+
let pi = await create(original.params, original.options);
|
|
270
|
+
if (pi.status === 'processing') pi = await providerClient.paymentIntents.retrieve(pi.id);
|
|
271
|
+
if (pi.status === 'requires_action') pi = await providerClient.paymentIntents.cancel(pi.id);
|
|
272
|
+
await recordProviderResult(env, o, id, pi);
|
|
273
|
+
return pi;
|
|
274
|
+
} catch (error) { await recordingFailure(env, id, error);
|
|
275
|
+
if (((error.statusCode ?? error.status) === 403 || /not available|not enabled|permission|country|eligib/i.test(error.message))) await notePaymentRefusal(env, params.payment_method_options?.crypto ? 'base' : 'card', error);
|
|
276
|
+
throw error; }
|
|
277
|
+
};
|
|
278
|
+
return client;
|
|
279
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { purchaseResponse } from './purchase-response.mjs';
|
|
2
|
+
import { machineCapabilities } from './payment-capabilities.mjs';
|
|
3
|
+
/** The SDK owns protocol negotiation, JSON-RPC errors and stateless HTTP transport. */
|
|
4
|
+
import { Server } from '@modelcontextprotocol/sdk/server/index.js';
|
|
5
|
+
import { WebStandardStreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js';
|
|
6
|
+
import { CallToolRequestSchema, ListToolsRequestSchema } from '@modelcontextprotocol/sdk/types.js';
|
|
7
|
+
import { machineResource } from './purchase-machine.mjs';
|
|
8
|
+
export const purchaseSchema = {
|
|
9
|
+
type: 'object', required: ['kind', 'resource', 'version', 'buyer', 'claim', 'offerVersion'],
|
|
10
|
+
properties: {
|
|
11
|
+
kind: { type: 'string' }, resource: { type: 'string' }, version: { type: 'string' },
|
|
12
|
+
buyer: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'A random private buyer identifier.' },
|
|
13
|
+
claim: { type: 'string', pattern: '^[a-f0-9]{64}$', description: 'A new private random 32-byte hex secret for this purchase. Keep it to retrieve this order.' },
|
|
14
|
+
offerVersion: { type: 'string', description: 'The published immutable offer version.' },
|
|
15
|
+
quantity: { type: 'integer', minimum: 1, default: 1 }, game: { type: 'string' },
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
export async function purchaseMcp(request, env, url, cat) {
|
|
19
|
+
let delivery;
|
|
20
|
+
const capability = await machineCapabilities(env);
|
|
21
|
+
const methods = Object.fromEntries([...(capability.machine.card ? ['stripe'] : []), ...(capability.machine.tempo ? ['tempo'] : [])].map((method) => [method, { intents: ['charge'] }]));
|
|
22
|
+
const server = new Server({ name: 'Studio purchases', version: '1' }, {
|
|
23
|
+
capabilities: { tools: {}, experimental: { payment: { methods } } },
|
|
24
|
+
});
|
|
25
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [{
|
|
26
|
+
name: 'purchase', description: 'Pay for the published offer and receive files, receipt and proof. Unpaid challenges expire after five minutes.', inputSchema: purchaseSchema,
|
|
27
|
+
}] }));
|
|
28
|
+
server.setRequestHandler(CallToolRequestSchema, async (input, extra) => {
|
|
29
|
+
if (input.params.name !== 'purchase') return { isError: true, content: [{ type: 'text', text: 'Unknown tool' }] };
|
|
30
|
+
const result = await machineResource(new Request(request.url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(input.params.arguments) }), env, url, cat, {
|
|
31
|
+
mcpInput: input.params.arguments, setDelivery: (data) => { delivery = data; }, extra: { ...extra, _meta: input.params._meta },
|
|
32
|
+
});
|
|
33
|
+
if (result instanceof Response) return { isError: true, content: [{ type: 'text', text: await result.text() }] };
|
|
34
|
+
return result;
|
|
35
|
+
});
|
|
36
|
+
const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
37
|
+
await server.connect(transport);
|
|
38
|
+
const response = await transport.handleRequest(request);
|
|
39
|
+
if (!delivery) return response;
|
|
40
|
+
const envelope = await response.json();
|
|
41
|
+
return purchaseResponse(delivery, env, { mcp: true, envelope });
|
|
42
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { selling } from './extensions.mjs';
|
|
2
|
+
const json = (v,status=200) => Response.json(v,{status});
|
|
3
|
+
export async function officeRoutes(request, env, path, body) {
|
|
4
|
+
if (path === '/_studio/api/shop/readiness' && request.method === 'POST') return json(await selling.testPaymentExchange(env));
|
|
5
|
+
if (['/_studio/api/shop/retire', '/_studio/api/shop/reissue', '/_studio/api/shop/test-access', '/_studio/api/shop/sync-offers', '/_studio/api/shop/reconcile-payments'].includes(path) && request.method === 'POST') {
|
|
6
|
+
try { if (path.endsWith('/reconcile-payments')) { return json({ ok: true, ...await selling.reconcileRecordings(env, body.order ?? null, { payment: body.payment ?? null, cancel: body.cancel === true }) }); }
|
|
7
|
+
if (path.endsWith('/test-access')) return json(await selling.authorizePurchaseTest(env, body.order));
|
|
8
|
+
if (path.endsWith('/sync-offers')) return json(await selling.expirePurchaseCheckouts(env));
|
|
9
|
+
return json(path.endsWith('/retire') ? await selling.retireResource(env, body.kind, body.resource) : await selling.reissuePurchaseClaim(env, body)); }
|
|
10
|
+
catch (error) {
|
|
11
|
+
if (error.status === 403 || error.status === 401) return json({ ok: false, message: 'Reconnect the shop with Checkout Sessions and Subscriptions Write permissions, then retry. New sales remain closed if retirement began.' }, 503);
|
|
12
|
+
if (/no such table/.test(error.message)) return json({ ok: false, message: 'Deploy the purchase migrations first' }, 503);
|
|
13
|
+
throw error;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const officeScript = String.raw` if (s.purchases) {
|
|
2
|
+
top.appendChild(el('h3', null, 'Machine payment readiness'));
|
|
3
|
+
(s.purchases.missing || []).forEach(function (reason) { top.appendChild(el('p', null, reason)); });
|
|
4
|
+
var exchange = s.purchases.exchange;
|
|
5
|
+
if (exchange) top.appendChild(el('p', null, exchange.verified ? exchange.scope : exchange.reason));
|
|
6
|
+
}
|
|
7
|
+
var recoveries = (s.settlementRecovery || []).concat(s.paymentRecovery || []);
|
|
8
|
+
if (recoveries.length) {
|
|
9
|
+
top.appendChild(el('h3', null, 'Payment recovery'));
|
|
10
|
+
recoveries.forEach(function (job) {
|
|
11
|
+
top.appendChild(el('p', null, job.order_id + ' · ' + (job.network || 'Stripe') + ' · ' + job.state + (job.request_key ? ' · request ' + job.request_key : '') + (job.payment ? ' · payment ' + job.payment : '') + (job.started_at ? ' · since ' + new Date(job.started_at).toISOString() : '') + (job.error ? ' · ' + job.error : '')));
|
|
12
|
+
if (job.request_key && job.payment) {
|
|
13
|
+
var cancel = el('button', { type: 'button' }, 'Cancel if unpaid');
|
|
14
|
+
cancel.addEventListener('click', function () {
|
|
15
|
+
if (!confirm('Read this payment from Stripe and cancel it only if it has not succeeded? A paid order stays paid and can be refunded separately.')) return;
|
|
16
|
+
cancel.disabled = true;
|
|
17
|
+
api('POST', '/_studio/api/shop/reconcile-payments', { order: job.order_id, cancel: true }).then(function (result) { if (!result.ok) throw new Error(result.message || 'Reconciliation failed'); load(); }).catch(function (error) { cancel.disabled = false; toast(error.message); });
|
|
18
|
+
});
|
|
19
|
+
top.appendChild(cancel);
|
|
20
|
+
}
|
|
21
|
+
if (job.request_key && !job.payment) {
|
|
22
|
+
var payment = el('input', { placeholder: 'Existing Stripe PaymentIntent id', 'aria-label': 'Existing Stripe PaymentIntent id' });
|
|
23
|
+
var read = el('button', { type: 'button' }, 'Read this payment');
|
|
24
|
+
read.addEventListener('click', function () {
|
|
25
|
+
read.disabled = true;
|
|
26
|
+
api('POST', '/_studio/api/shop/reconcile-payments', { order: job.order_id, payment: payment.value.trim() }).then(function (result) { if (!result.ok) throw new Error(result.message || 'Read failed'); load(); }).catch(function (error) { read.disabled = false; toast(error.message); });
|
|
27
|
+
});
|
|
28
|
+
top.appendChild(payment); top.appendChild(read);
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
var reconcile = el('button', { type: 'button' }, 'Reconcile payments now');
|
|
32
|
+
reconcile.addEventListener('click', function () {
|
|
33
|
+
reconcile.disabled = true;
|
|
34
|
+
api('POST', '/_studio/api/shop/reconcile-payments', {}).then(function (result) { if (!result.ok) throw new Error(result.message || 'Reconciliation failed'); load(); }).catch(function (error) { reconcile.disabled = false; toast(error.message); });
|
|
35
|
+
});
|
|
36
|
+
top.appendChild(reconcile);
|
|
37
|
+
}
|
|
38
|
+
`;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/** Optional resource rows in the existing shop office. */
|
|
2
|
+
import { hasStripeKey, shopMode } from './shop-links.mjs';
|
|
3
|
+
import { dashboardLink, refundPayment, cancelSubscription, redactStripe } from './stripe.mjs';
|
|
4
|
+
import { currentPurchaseOrder, purchaseReadiness } from './purchase-owner.mjs';
|
|
5
|
+
import { reconcileOrder } from './purchase-reconciliation.mjs';
|
|
6
|
+
import { termsOf, byId } from './purchase-store.mjs';
|
|
7
|
+
import { manualRefundDetails } from './payment-manual-refund.mjs';
|
|
8
|
+
import { jobsSQL } from './payment-policy.mjs';
|
|
9
|
+
import { money } from './shop-rules.mjs';
|
|
10
|
+
export async function refundOrder(env, order, { reason = 'requested_by_customer', by = 'owner' } = {}) {
|
|
11
|
+
if (order.mode !== shopMode(env)) return { ok: false, error: 'mode', message: 'Reconnect the payment mode of this order.' };
|
|
12
|
+
if (!hasStripeKey(env)) return { ok: false, error: 'stripe-dashboard', message: 'Refund in Stripe or ask your connected AI. Its signed refund event updates this purchase.', url: dashboardLink(order.mode, 'payment', order.payment) };
|
|
13
|
+
let o;
|
|
14
|
+
try { o = await currentPurchaseOrder(env, order.id) ?? order; } catch (error) { return {ok:false,error:'stripe-retry',message:redactStripe(error.message,env)}; }
|
|
15
|
+
if (o.status === 'refunded') return { ok: true, order: o.id, status: 'succeeded', already: true };
|
|
16
|
+
if (!['paid','fulfilled'].includes(o.status)) return { ok: false, error: 'state', message: `This order is ${o.status}.` };
|
|
17
|
+
if (!o.payment) { const manual = await manualRefundDetails(env, o); return { ok: false, error: manual ? 'manual-refund-required' : 'no-payment', ...manual }; }
|
|
18
|
+
try {
|
|
19
|
+
const refund = await refundPayment(env, o.payment, { reason, metadata: { order: o.id, by } });
|
|
20
|
+
await reconcileOrder(env, o);
|
|
21
|
+
if (refund.status !== 'succeeded') return { ok: true, pending: true, order: o.id, status: refund.status };
|
|
22
|
+
if (o.subscription && termsOf(o).refundEndsSubscription === true) await cancelSubscription(env, o.subscription);
|
|
23
|
+
return { ok: true, order: o.id, refund: refund.id, status: refund.status, amount: refund.amount ?? o.amount, currency: o.currency };
|
|
24
|
+
} catch (error) { return { ok: false, error: 'stripe', message: redactStripe(error.message, env) }; }
|
|
25
|
+
}
|
|
26
|
+
export async function extendOffice(env, cat, origin, out) {
|
|
27
|
+
try {
|
|
28
|
+
out.purchases = await purchaseReadiness(env, origin, cat);
|
|
29
|
+
const rows = (await env.DB.prepare('SELECT o.*, r.manifest AS manifest FROM purchase_orders o JOIN resource_snapshots r ON r.hash=o.manifest_hash ORDER BY o.created_at DESC LIMIT 100').all()).results ?? [];
|
|
30
|
+
for (const r of rows) out.orders.push({ id: r.id, status: r.status, resourceKind: r.resource_kind, resource: r.resource_id, name: JSON.parse(r.manifest).name, shown: money(r.amount,r.currency), amount:r.amount, currency:r.currency, createdAt:r.created_at, mode:r.mode, player:{name:r.buyer_label}, stripe:r.payment ? dashboardLink(r.mode,'payment',r.payment):null, refundable:hasStripeKey(env) && ['paid','fulfilled'].includes(r.status), manualRefund:await manualRefundDetails(env,r) });
|
|
31
|
+
out.orders.sort((a,b)=>b.createdAt-a.createdAt);
|
|
32
|
+
out.paymentRecovery = (await env.DB.prepare(`SELECT id AS request_key, order_id, state, payment, error, attempts, updated_at FROM purchase_jobs WHERE ${jobsSQL('visible')} ORDER BY updated_at`).all()).results ?? [];
|
|
33
|
+
out.settlementRecovery = (await env.DB.prepare('SELECT order_id, network, state, reference, error, created_at, updated_at FROM purchase_settlements ORDER BY created_at').all()).results ?? [];
|
|
34
|
+
} catch (error) { if (!/no such table/.test(error.message)) throw error; out.purchases = {ready:false,missing:['Deploy the purchase migrations']}; }
|
|
35
|
+
}
|