@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.
Files changed (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bin/homie-studio.mjs +7 -6
  3. package/lib/cloudflare.mjs +28 -3
  4. package/lib/dev.mjs +1 -1
  5. package/lib/parts-build.mjs +23 -4
  6. package/lib/parts-cli.mjs +54 -3
  7. package/lib/parts-purchase.mjs +174 -0
  8. package/lib/parts-store.mjs +50 -19
  9. package/lib/parts-tools.mjs +15 -12
  10. package/lib/parts-upload.mjs +43 -0
  11. package/lib/parts.mjs +49 -5
  12. package/lib/perf.mjs +17 -10
  13. package/lib/port-check.mjs +77 -18
  14. package/lib/purchase-links.mjs +42 -0
  15. package/lib/purchase-wallet.mjs +114 -0
  16. package/lib/rules-check-worker.mjs +15 -2
  17. package/lib/scaffold.mjs +25 -5
  18. package/lib/shoot.mjs +12 -6
  19. package/lib/shop-links.mjs +15 -6
  20. package/lib/shop.mjs +91 -21
  21. package/lib/stripe-connect.mjs +2 -2
  22. package/lib/template-history.json +85 -0
  23. package/netplay/NETPLAY.md +20 -6
  24. package/netplay/netplay.ts +3 -1
  25. package/package.json +12 -2
  26. package/parts/COMMERCIAL-LICENCE.md +21 -0
  27. package/parts/PARTS.md +238 -10
  28. package/port/probe.ts +2 -1
  29. package/rules/core.ts +3 -1
  30. package/rules/host.ts +3 -1
  31. package/rules/pack.ts +30 -3
  32. package/rules/view.ts +232 -40
  33. package/starters/coin-dash/lab.json +17 -0
  34. package/starters/coin-dash/src/rules.ts +3 -3
  35. package/starters/gem-rush/game.json +19 -4
  36. package/starters/gem-rush/map/main.json +50 -0
  37. package/starters/gem-rush/src/move.ts +23 -0
  38. package/starters/gem-rush/src/rules.ts +118 -0
  39. package/starters/gem-rush/src/view.ts +429 -0
  40. package/starters/gem-rush/tunables.json +98 -11
  41. package/worker/extensions.mjs +13 -0
  42. package/worker/index.mjs +3 -2
  43. package/worker/machine-payments.mjs +72 -0
  44. package/worker/office.mjs +4 -1
  45. package/worker/parts-protected.mjs +111 -0
  46. package/worker/parts-purchase-catalogue.mjs +34 -0
  47. package/worker/parts-resource.mjs +32 -0
  48. package/worker/parts-sale.mjs +59 -0
  49. package/worker/parts.mjs +24 -11
  50. package/worker/payment-capabilities.mjs +70 -0
  51. package/worker/payment-dry-exchange.mjs +30 -0
  52. package/worker/payment-manual-refund.mjs +40 -0
  53. package/worker/payment-policy.mjs +40 -0
  54. package/worker/payment-recovery.mjs +163 -0
  55. package/worker/payment-test-exchange.mjs +19 -0
  56. package/worker/purchase-core.mjs +140 -0
  57. package/worker/purchase-crypto.mjs +17 -0
  58. package/worker/purchase-delivery.mjs +108 -0
  59. package/worker/purchase-discovery.mjs +26 -0
  60. package/worker/purchase-hosted-readiness.mjs +16 -0
  61. package/worker/purchase-keys.mjs +20 -0
  62. package/worker/purchase-links.mjs +9 -0
  63. package/worker/purchase-machine.mjs +279 -0
  64. package/worker/purchase-mcp.mjs +42 -0
  65. package/worker/purchase-office-routes.mjs +17 -0
  66. package/worker/purchase-office-script.mjs +38 -0
  67. package/worker/purchase-office.mjs +35 -0
  68. package/worker/purchase-owner.mjs +243 -0
  69. package/worker/purchase-pricing.mjs +8 -0
  70. package/worker/purchase-problem.mjs +4 -0
  71. package/worker/purchase-reconciliation.mjs +582 -0
  72. package/worker/purchase-response.mjs +82 -0
  73. package/worker/purchase-routes.mjs +611 -0
  74. package/worker/purchase-schema.mjs +50 -0
  75. package/worker/purchase-store.mjs +148 -0
  76. package/worker/purchases.mjs +6 -0
  77. package/worker/resource-kinds.mjs +20 -0
  78. package/worker/selling/index.mjs +8 -0
  79. package/worker/selling/runtime.mjs +42 -0
  80. package/worker/settlement-journal.mjs +138 -0
  81. package/worker/shop-page.mjs +7 -4
  82. package/worker/shop-refunds.mjs +22 -0
  83. package/worker/shop-rules.mjs +1 -1
  84. package/worker/shop.mjs +18 -18
  85. package/worker/stripe.mjs +36 -3
  86. package/worker/version.mjs +1 -1
  87. package/starters/gem-rush/src/main.ts +0 -1087
@@ -0,0 +1,70 @@
1
+ /** Stripe-backed, non-settling permission checks. Never uses a card or a wallet. */
2
+ import { stripeCall, modeOf } from './stripe.mjs';
3
+ import { digest } from "./purchase-crypto.mjs";
4
+
5
+ export const MACHINE_API_VERSION = '2026-07-29.preview';
6
+ export async function probeMachinePayments(env, { fetcher = fetch } = {}) {
7
+ const mode = modeOf(env.STRIPE_KEY);
8
+ const result = { mode, key: await digest(env.STRIPE_KEY), checkedAt: Date.now(), machine: { card: false, tempo: false, base: false }, reasons: {}, configuration: {} };
9
+ const call = (method, path, params) => stripeCall(env, method, path, params, { fetcher, version: MACHINE_API_VERSION });
10
+ try {
11
+ const profile = await call('GET', '/v2/network/business_profiles/me');
12
+ if (!/^profile_/.test(profile?.id ?? '')) throw new Error('Create a Stripe business profile in the Dashboard');
13
+ result.configuration.profile = profile.id;
14
+ result.machine.card = true;
15
+ } catch (e) {
16
+ result.reasons.card = `${e.message}. Enable PaymentIntents Write and access to Business Profiles on this restricted key, create the account profile, then reconnect shop. SPT eligibility is controlled by Stripe.`;
17
+ }
18
+ try {
19
+ const configs = await call('GET', '/v1/payment_method_configurations', { limit: 100 });
20
+ if (!configs.data?.some((c) => c.active && c.crypto?.available && c.crypto?.display_preference?.value === 'on')) throw new Error('Stablecoins and Crypto is not active for this Stripe account. Request it in Stripe payment method settings; Stripe reviews eligibility and country support');
21
+ for (const network of ['base']) {
22
+ try {
23
+ const listed = await call('GET', '/v1/crypto/deposit_addresses', { network });
24
+ const address = listed.data?.find((a) => /^0x[0-9a-fA-F]{40}$/.test(a.address));
25
+ if (!address || !/^0x[0-9a-fA-F]{40}$/.test(address.address)) throw new Error('Provision a Base deposit address in Stripe, then reconnect; this read-only check creates nothing');
26
+ result.configuration[network] = address.address;
27
+ result.machine[network] = true;
28
+ } catch (e) { result.reasons[network] = `${e.message}. Enable Crypto Deposit Addresses access on this key and reconnect.`; }
29
+ }
30
+ } catch (e) { result.reasons.tempo = result.reasons.base = `${e.message}. Reconnect after Stripe enables this payment method.`; }
31
+ result.reasons.tempo = 'Tempo is disabled until an official client completes route tests in both modes.';
32
+ return result;
33
+ }
34
+ export async function machineCapabilities(env) {
35
+ try {
36
+ const cached = env.DB && await env.DB.prepare("SELECT value FROM meta WHERE key = 'purchase-capabilities'").first();
37
+ const configured = JSON.parse(env.PURCHASE_PAYMENT_CAPABILITIES ?? '{}');
38
+ const saved = cached ? JSON.parse(cached.value) : null;
39
+ const record = saved?.key === configured.key && saved.checkedAt >= configured.checkedAt ? saved : configured;
40
+ const configuration = JSON.parse(env.PURCHASE_MACHINE_PAYMENTS ?? '{}')[modeOf(env.STRIPE_KEY)];
41
+ if (record.mode !== modeOf(env.STRIPE_KEY) || record.key !== await digest(env.STRIPE_KEY) || !configuration?.profile)
42
+ return { ready: false, machine: {}, reasons: { setup: 'Reconnect shop to verify machine payment permissions and obtain the Stripe profile and deposit addresses.' } };
43
+ const machine = { ...record.machine, tempo: false };
44
+ if (configuration.manualRefunds !== true || !configuration.base || (record.mode === 'live' && (!env.CDP_API_KEY_ID || !env.CDP_API_KEY_SECRET))) machine.base = false;
45
+ return { ...record, machine, ready: Object.values(machine).some(Boolean) };
46
+ } catch { return { ready: false, machine: {}, reasons: { setup: 'Reconnect shop to repair its payment configuration.' } }; }
47
+ }
48
+
49
+ /** Scheduled checks share connect's probe. Office and public discovery never probe Stripe. */
50
+ export async function refreshMachineCapabilities(env) {
51
+ const prior = await machineCapabilities(env);
52
+ if (prior.nextCheckAt > Date.now()) return prior;
53
+ const record = await probeMachinePayments(env);
54
+ const success = Object.values(record.machine).some(Boolean);
55
+ record.failures = success ? 0 : (prior.failures ?? 0) + 1;
56
+ record.nextCheckAt = Date.now() + (success ? 6 * 3600000 : Math.min(6 * 3600000, 300000 * 2 ** Math.min(record.failures - 1, 6)));
57
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES (?1,?2)")
58
+ .bind('purchase-capabilities', JSON.stringify(record)).run();
59
+ return record;
60
+ }
61
+ export async function notePaymentRefusal(env, method, error) {
62
+ if ((error.statusCode ?? error.status) === 402 || error.type === 'card_error' || error.raw?.type === 'card_error') return;
63
+ const record = await machineCapabilities(env);
64
+ record.machine[method] = false;
65
+ record.reasons = { ...record.reasons, [method]: String(error.message).slice(0,500) };
66
+ record.checkedAt = Date.now();
67
+ record.nextCheckAt = 0;
68
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES (?1,?2)")
69
+ .bind('purchase-capabilities', JSON.stringify(record)).run();
70
+ }
@@ -0,0 +1,30 @@
1
+ /** Owner-only dry exchange through the real resource endpoint, without a wallet credential. */
2
+ export async function dryPaymentExchange(env, origin, catalogue = {}) {
3
+ const { listOffers } = await import('./resource-kinds.mjs');
4
+ const resource = (await listOffers(env,origin)).find((r)=>r.offer.billing === 'one-time' && r.offer.currency === 'usd' && !r.offer.automaticTax && r.offer.taxBehavior !== 'exclusive');
5
+ const release = resource;
6
+ if (!release) return { verified: false, reason: 'No listed offer is eligible for a machine-payment challenge. Hosted Checkout remains available.' };
7
+ const { machineResource } = await import('./purchase-machine.mjs');
8
+ const { digest } = await import('./purchase-crypto.mjs');
9
+ const { byClaim } = await import('./purchase-store.mjs');
10
+ const claim = await digest(crypto.randomUUID());
11
+ const claimHash = await digest(claim);
12
+ try {
13
+ const url = new URL(`/api/purchases/resource/${resource.kind}/${resource.id}/${release.version}`, origin);
14
+ const request = new Request(url, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ kind: resource.kind, resource: resource.id, version: release.version, buyer: await digest(crypto.randomUUID()), claim, offerVersion: release.offerVersion, ...(release.offer.scope === 'game' ? { game: 'readiness-check' } : {}) }) });
15
+ const challenge = await machineResource(request, env, url, catalogue, { dryRun: true });
16
+ if (challenge.status !== 402) return { verified: false, reason: (await challenge.json()).detail ?? `Dry exchange answered ${challenge.status}` };
17
+ const { Challenge } = await import('mppx');
18
+ const parsed = Challenge.fromResponse(challenge);
19
+ if (parsed.realm !== url.hostname || !parsed.expires) throw new Error('Challenge failed realm or expiry verification');
20
+ return { verified: true, checkedAt: Date.now(), resource: resource.id, version: release.version, challenge: { method: parsed.method, intent: parsed.intent, realm: parsed.realm, expires: parsed.expires }, scope: 'A real unpaid request passed offer, storage, signing-key and Stripe permission checks and returned a parseable payment challenge. No wallet credential was submitted. Token eligibility, live settlement and refunds are not verified by this dry exchange.' };
21
+ } catch (error) { return { verified: false, reason: error.message }; }
22
+ finally {
23
+ const order = await byClaim(env, claimHash);
24
+ if (order) await env.DB.batch([
25
+ env.DB.prepare("DELETE FROM purchase_orders WHERE id = ?1 AND status = 'started'").bind(order.id),
26
+ env.DB.prepare("DELETE FROM resource_snapshots WHERE hash = ?1 AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE manifest_hash = ?1)").bind(order.manifest_hash),
27
+ env.DB.prepare("DELETE FROM purchase_offers WHERE version = ?1 AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE offer_version = ?1)").bind(order.offer_version),
28
+ ]);
29
+ }
30
+ }
@@ -0,0 +1,40 @@
1
+ /** Exact chain evidence for the studio's explicitly accepted manual refund duty. */
2
+ export async function manualRefundDetails(env, order) {
3
+ if (order.payment || !order.paid_at) return null;
4
+ const rows = (await env.DB.prepare("SELECT network,reference,payload FROM purchase_settlements WHERE order_id = ?1 AND state IN ('settled','complete')").bind(order.id).all()).results ?? [];
5
+ const transfers = rows.map((row) => {
6
+ const data = JSON.parse(row.payload);
7
+ const auth = data.payload?.payload?.authorization;
8
+ return { network: row.network, transaction: row.reference ?? data.reference, payer: auth?.from ?? null,
9
+ amount: auth?.value ?? null, token: data.requirements?.asset ?? null, recipient: auth?.to ?? null };
10
+ });
11
+ return { status: 'manual_refund_required', order: order.id, delivered: Boolean(order.fulfilled_at), transfers,
12
+ message: 'Stripe has not recorded this chain payment. The selling studio owes any requested refund directly to the original payer. Use the exact network, token, amount and transaction below; record the refund transaction in the order note. Do not mark refunded until the transfer is confirmed.' };
13
+ }
14
+
15
+ /** Owner-supplied refund transaction must actually return the exact token amount to the payer. */
16
+ export async function confirmManualRefund(env, order, transaction) {
17
+ if (!/^0x[0-9a-fA-F]{64}$/.test(transaction ?? '')) return { ok: false, error: 'refund-transaction', message: 'Supply the confirmed Base refund transaction hash.' };
18
+ const manual = await manualRefundDetails(env, order);
19
+ if (!manual?.transfers.length || manual.transfers.some((t) => t.network !== 'base')) return { ok:false, error:'refund-evidence', message:'Verified Base transfers are required.' };
20
+ const original = manual.transfers[0];
21
+ if (manual.transfers.some((t) => ['payer','token','recipient','amount'].some((key) => t[key]?.toLowerCase() !== original[key]?.toLowerCase()))) return { ok: false, error: 'refund-evidence', message: 'These transfers need separate refund evidence.' };
22
+ if (transaction.toLowerCase() === original.transaction?.toLowerCase()) return {ok:false,error:'refund-evidence',message:'The original payment is not a refund.'};
23
+ const { rpcFor } = await import('./settlement-journal.mjs');
24
+ const rpc = rpcFor(env,'base',order.mode);
25
+ const receipt = await rpc('eth_getTransactionReceipt',[transaction]);
26
+ const paidReceipt = await rpc('eth_getTransactionReceipt',[original.transaction]);
27
+ if (!receipt?.blockNumber || !paidReceipt?.blockNumber || BigInt(receipt.blockNumber) <= BigInt(paidReceipt.blockNumber)) return { ok: false, error: 'refund-evidence', message: 'The refund must be mined after the original payment.' };
28
+ const block = await rpc('eth_getBlockByNumber', [receipt.blockNumber, false]);
29
+ if (!block?.timestamp || Number(BigInt(block.timestamp)) * 1000 <= order.created_at) return { ok: false, error: 'refund-evidence', message: 'The refund must be sent after this order.' };
30
+ const { keccak256, toHex } = await import('viem');
31
+ const topic = keccak256(toHex('Transfer(address,address,uint256)'));
32
+ if (receipt?.status !== '0x1' || !receipt.logs?.some((log) => log.address.toLowerCase() === original.token.toLowerCase() && log.topics[0] === topic && log.topics[1]?.slice(-40).toLowerCase() === original.recipient.slice(2).toLowerCase() && log.topics[2]?.slice(-40).toLowerCase() === original.payer.slice(2).toLowerCase() && BigInt(log.data) === BigInt(original.amount)))
33
+ return {ok:false,error:'refund-evidence',message:'The chain receipt does not prove the exact refund to the original payer.'};
34
+ const { orderFact } = await import('./purchase-core.mjs');
35
+ await env.DB.batch([
36
+ env.DB.prepare('INSERT INTO purchase_manual_refunds (transaction_hash,order_id) VALUES (?1,?2)').bind(transaction.toLowerCase(),order.id),
37
+ orderFact(env,order.id,'refunded',{refund:transaction,refunded_at:Date.now(),note:JSON.stringify({sender:original.recipient,payer:original.payer,amount:original.amount,transaction})}),
38
+ ]);
39
+ return {ok:true,order:order.id,status:'succeeded',refund:transaction};
40
+ }
@@ -0,0 +1,40 @@
1
+ /** One vocabulary for recovery, expiry, buyer locks and office status. */
2
+ export const jobStates = Object.freeze({
3
+ submitted: { pending: true, terminal: false, blocks: true },
4
+ paid_recording: { pending: true, terminal: false, blocks: true },
5
+ recorded: { pending: true, terminal: false, blocks: true },
6
+ processing: { pending: true, terminal: false, blocks: true },
7
+ duplicate_refund: { pending: true, terminal: false, blocks: true },
8
+ failed: { pending: false, terminal: true, blocks: false },
9
+ complete: { pending: false, terminal: true, blocks: false },
10
+ refunded: { pending: false, terminal: true, blocks: false },
11
+ duplicate_refunded: { pending: false, terminal: true, blocks: false },
12
+ manual_review: { pending: true, terminal: false, blocks: true },
13
+ });
14
+ export function jobPolicy(state) {
15
+ if (!jobStates[state]) throw new Error(`Unknown payment job state: ${state}`);
16
+ return jobStates[state];
17
+ }
18
+ export function jobsSQL(property, prefix = '') {
19
+ const names = Object.entries(jobStates).filter(([state, policy]) => property === 'visible' ? policy.pending || ['failed','manual_review'].includes(state) : property === 'cleanup' ? state === 'failed' : policy[property]).map(([state]) => `'${state}'`);
20
+ return `${prefix}state IN (${names.join(',')})`;
21
+ }
22
+ export const pendingJobsSQL = jobsSQL('pending');
23
+ export const blockingJobsSQL = jobsSQL('blocks');
24
+ export const REPLAY_LIMIT_MS = 23 * 60 * 60 * 1000;
25
+ export function definitiveRefusal(error) {
26
+ const status = error.statusCode ?? error.status;
27
+ if ([401,403,408,409,429].includes(status) || error.type === 'idempotency_error') return false;
28
+ if (error.idempotentReplayed && status >= 400 && status < 500) return true;
29
+ return status === 402 && (error.type === 'card_error' || error.raw?.type === 'card_error');
30
+ }
31
+ /** Ordered decision table. Storage faults can never authorize a refund. */
32
+ export function recoveryAction({ state, fulfilled, provider, files, settled, deadline = false }) {
33
+ if (fulfilled || state === 'fulfilled') return 'final';
34
+ if (['refunded','disputed','lapsed','lost'].includes(state)) return 'preserve';
35
+ if (provider === 'paid' || settled || state === 'paid') return files === 'present' ? 'deliver' : 'storage_retry';
36
+ if (provider === 'refused' || provider === 'absent' && deadline) return 'fail';
37
+ if (provider === 'absent') return 'replay';
38
+ if (provider === 'processing') return 'wait';
39
+ return 'reconcile';
40
+ }
@@ -0,0 +1,163 @@
1
+ import { pendingJobsSQL, definitiveRefusal, REPLAY_LIMIT_MS, recoveryAction } from './payment-policy.mjs';
2
+ import { orderFact, recordOrderState, releaseClaim } from './purchase-core.mjs';
3
+ import { resumeSettlement } from './settlement-journal.mjs';
4
+ /** Durable provider recordings outlive responses. A verified chain transfer is payment. */
5
+ import { stripeCall, refundPayment, modeOf } from './stripe.mjs';
6
+ import { byId, resourceAvailable } from './purchase-store.mjs';
7
+ export async function saveRecording(env, order, params, options) {
8
+ const chain = params.payment_method_options?.crypto?.transaction_verification_options;
9
+ const id = chain ? `${order.id}:${chain.network}:${chain.transaction_hash}` : options.idempotencyKey;
10
+ await env.DB.prepare("INSERT OR IGNORE INTO purchase_jobs (id,order_id,mode,state,payload,updated_at) VALUES (?1,?2,?3,?4,?5,?6)")
11
+ .bind(id, order.id, order.mode, chain ? 'paid_recording' : 'submitted', JSON.stringify({ params, options: { ...options, idempotencyKey: chain ? chain.transaction_hash : options.idempotencyKey }, chain, startedAt: Date.now() }), Date.now()).run();
12
+ if (chain) {
13
+ // Only the protocol's verified settlement callback reaches here, never buyer input.
14
+ await orderFact(env, order.id, 'payment', {paid_at: Date.now()}).run();
15
+ await env.DB.prepare("UPDATE purchase_settlements SET state = 'complete', updated_at = ?2 WHERE order_id = ?1 AND reference = ?3").bind(order.id, Date.now(), chain.transaction_hash).run();
16
+ }
17
+ return id;
18
+ }
19
+ export async function recordProviderResult(env, order, id, pi) {
20
+ if (pi.amount !== order.amount || pi.currency !== order.currency || pi.livemode !== (order.mode === 'live')) throw new Error('Provider receipt does not match order');
21
+ await env.DB.prepare("UPDATE purchase_jobs SET payment = ?2, state = ?3, error = NULL, updated_at = ?4 WHERE id = ?1").bind(id, pi.id, pi.status === 'succeeded' ? 'recorded' : ['canceled','requires_payment_method'].includes(pi.status) ? 'failed' : 'processing', Date.now()).run();
22
+ if (pi.status !== 'succeeded') return;
23
+
24
+ await env.DB.batch([
25
+ orderFact(env, order.id, 'payment', {payment: pi.id, paid_at: Date.now()}),
26
+ env.DB.prepare("INSERT OR IGNORE INTO purchase_payments (payment,order_id) VALUES (?1,?2)").bind(pi.id, order.id),
27
+ ]);
28
+ const current = await byId(env, order.id);
29
+ if (current.payment !== pi.id) {
30
+ const refund = await refundPayment(env, pi.id);
31
+ await env.DB.prepare("UPDATE purchase_jobs SET state = ?2 WHERE id = ?1").bind(id, refund.status === 'succeeded' ? 'duplicate_refunded' : 'duplicate_refund').run();
32
+ return;
33
+ }
34
+ }
35
+ export async function recordingFailure(env, id, error) {
36
+ if (definitiveRefusal(error)) {
37
+ await env.DB.prepare("UPDATE purchase_jobs SET state = 'failed', error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1 AND state = 'submitted'").bind(id, String(error.message).slice(0, 500), Date.now()).run();
38
+ return;
39
+ }
40
+ // A failure of our call is not a fact about the original payment.
41
+ await env.DB.prepare("UPDATE purchase_jobs SET error = ?2, attempts = attempts + 1, updated_at = ?3 WHERE id = ?1")
42
+ .bind(id, String(error.message).slice(0, 500), Date.now()).run();
43
+ }
44
+
45
+ /** Read existing objects first. Listing avoids the search index's delayed negative answers. */
46
+ export async function readProviderPayment(env, order, job, payment = null) {
47
+ const options = { version: '2026-07-29.preview' };
48
+ if (payment && !/^pi_[A-Za-z0-9]+$/.test(payment)) throw new Error('Supply an existing Stripe PaymentIntent id');
49
+ if (job.payment || payment) {
50
+ const pi = await stripeCall(env, 'GET', `/v1/payment_intents/${encodeURIComponent(job.payment ?? payment)}`, null, options);
51
+ if (pi.metadata?.order !== order.id) throw new Error('The existing PaymentIntent belongs to another order');
52
+ return pi;
53
+ }
54
+ let cursor;
55
+ for (let page = 0; page < 100; page++) {
56
+ const result = await stripeCall(env, 'GET', '/v1/payment_intents', { limit: 100, created: { gte: Math.floor(order.created_at / 1000) - 60 }, ...(cursor ? { starting_after: cursor } : {}) }, options);
57
+ if (!Array.isArray(result.data)) throw new Error('Stripe did not return an existing-payment list');
58
+ const found = result.data.find((pi) => pi.metadata?.order === order.id);
59
+ if (found) return found;
60
+ if (!result.has_more) return null;
61
+ cursor = result.data.at(-1)?.id;
62
+ if (!cursor) throw new Error('Stripe payment list has no continuation');
63
+ }
64
+ throw new Error(`Existing payment scan exceeded 100 pages for order ${order.id}; the owner must reconcile this order.`);
65
+ }
66
+ async function releaseUnpaid(env, order, job, pi = null) {
67
+ await env.DB.batch([
68
+ env.DB.prepare("UPDATE purchase_jobs SET state = 'failed', error = NULL, updated_at = ?2, payment = COALESCE(payment, ?3) WHERE id = ?1").bind(job.id, Date.now(), pi?.id ?? null),
69
+ orderFact(env, order.id, 'failed', {}),
70
+ releaseClaim(env, order.id),
71
+ ]);
72
+ }
73
+ export async function reconcileRecordings(env, orderId = null, dependencies = {}) {
74
+ await reconcileSettlements(env, orderId, dependencies);
75
+ const rows = (await env.DB.prepare(`SELECT * FROM purchase_jobs WHERE mode = ?1 AND ${pendingJobsSQL} AND (?2 IS NULL OR order_id = ?2) ORDER BY updated_at LIMIT 100`).bind(modeOf(env.STRIPE_KEY), orderId).all()).results ?? [];
76
+ for (const job of rows) {
77
+ try {
78
+ const order = await byId(env, job.order_id);
79
+ if (!order) continue;
80
+ const { params, options, startedAt } = JSON.parse(job.payload);
81
+ // An interruption always starts with a read, even inside the replay window.
82
+ let pi = await readProviderPayment(env, order, job, orderId ? dependencies.payment : null);
83
+ const deadline = Date.now() - (startedAt ?? order.created_at) >= REPLAY_LIMIT_MS;
84
+ const observation = { state: order.status, fulfilled: order.fulfilled_at, settled: job.state === 'paid_recording' || Boolean(order.paid_at) || Boolean(JSON.parse(job.payload).chain),
85
+ provider: pi ? pi.status === 'succeeded' ? 'paid' : ['canceled','requires_payment_method'].includes(pi.status) ? 'refused' : 'processing' : 'absent', deadline };
86
+ if (observation.settled && pi && pi.status !== 'succeeded') throw new Error(`Settled transfer needs Stripe recording: order ${order.id}; payment ${pi.id}`);
87
+ switch (recoveryAction(observation)) {
88
+ case 'fail':
89
+ if (pi?.status === 'requires_payment_method') {
90
+ pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: '2026-07-29.preview' });
91
+ if (pi.status !== 'canceled') throw new Error('The unpaid payment could not be canceled; the claim remains reserved');
92
+ }
93
+ await releaseUnpaid(env, order, job, pi); continue;
94
+ case 'replay': pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: '2026-07-29.preview' }); break;
95
+ default:
96
+ if (!pi) {
97
+ if (deadline) throw new Error(`Paid transfer still needs recording: order ${order.id}; key ${options.idempotencyKey}`);
98
+ pi = await stripeCall(env, 'POST', '/v1/payment_intents', params, { ...options, version: '2026-07-29.preview' });
99
+ }
100
+ }
101
+ if (['requires_action','requires_confirmation','requires_capture','requires_payment_method'].includes(pi.status) || orderId && dependencies.cancel === true && pi.status === 'processing') pi = await stripeCall(env, 'POST', `/v1/payment_intents/${encodeURIComponent(pi.id)}/cancel`, {}, { idempotencyKey: `cancel-action-${pi.id}`, version: '2026-07-29.preview' });
102
+ await recordProviderResult(env, order, job.id, pi);
103
+ if (['canceled','requires_payment_method'].includes(pi.status)) { await releaseUnpaid(env, order, job, pi); continue; }
104
+ if (pi.status !== 'succeeded') {
105
+ if (Date.now() - (startedAt ?? order.created_at) >= REPLAY_LIMIT_MS && !['canceled','requires_payment_method'].includes(pi.status))
106
+ await env.DB.prepare("UPDATE purchase_jobs SET state = 'manual_review', error = 'Provider payment remains unresolved after deadline' WHERE id = ?1").bind(job.id).run();
107
+ continue;
108
+ }
109
+ if ((await byId(env, order.id)).payment !== pi.id) continue;
110
+ const current = await byId(env, order.id);
111
+ const final = recoveryAction({ state: current.status, fulfilled: current.fulfilled_at, provider: 'paid', settled: false, files: 'missing' });
112
+ if (final === 'final' || final === 'preserve') {
113
+ await env.DB.prepare("UPDATE purchase_jobs SET state = 'complete', updated_at = ?2 WHERE id = ?1").bind(job.id, Date.now()).run();
114
+ continue;
115
+ }
116
+ const available = await resourceAvailable(env, order.resource_kind, JSON.parse(order.manifest));
117
+ const action = recoveryAction({ state: current.status, fulfilled: current.fulfilled_at, provider: 'paid', settled: false, files: available ? 'present' : 'missing' });
118
+ if (action === 'storage_retry') throw new Error('Paid files unavailable. Restore storage; payment is retained and delivery will retry.');
119
+ if (action === 'deliver') {
120
+ await recordOrderState(env, current, 'fulfilled');
121
+ await env.DB.prepare("UPDATE purchase_jobs SET state = 'complete', updated_at = ?2 WHERE id = ?1").bind(job.id, Date.now()).run();
122
+ }
123
+ } catch (error) { await recordingFailure(env, job.id, error); }
124
+ }
125
+ return { checked: rows.length };
126
+ }
127
+
128
+ /** Resume even when the original isolate died before the provider callback. */
129
+ export async function reconcileSettlements(env, orderId = null, dependencies = {}) {
130
+ const jobs = (await env.DB.prepare("SELECT * FROM purchase_settlements WHERE mode = ?1 AND state NOT IN ('complete','unpaid') AND (?2 IS NULL OR order_id = ?2) ORDER BY updated_at LIMIT 100").bind(modeOf(env.STRIPE_KEY), orderId).all()).results ?? [];
131
+ for (const job of jobs) {
132
+ try {
133
+ const order = await byId(env, job.order_id);
134
+ if (!order) throw new Error('Settlement order is missing');
135
+ const reference = await resumeSettlement(env, job, dependencies);
136
+ if (!reference) {
137
+ await env.DB.prepare('UPDATE purchase_settlements SET updated_at = ?2 WHERE id = ?1').bind(job.id, Date.now()).run();
138
+ continue;
139
+ }
140
+ if (reference.manual) {
141
+ await env.DB.prepare("UPDATE purchase_settlements SET state = 'manual_review', error = ?2, updated_at = ?3 WHERE id = ?1").bind(job.id, reference.reason, Date.now()).run();
142
+ continue;
143
+ }
144
+ if (reference.unpaid) {
145
+ await env.DB.batch([
146
+ env.DB.prepare("UPDATE purchase_settlements SET state = 'unpaid', error = ?2, updated_at = ?3 WHERE id = ?1").bind(job.id, reference.reason, Date.now()),
147
+ orderFact(env, order.id, 'expired', {}),
148
+ releaseClaim(env, order.id),
149
+ ]);
150
+ continue;
151
+ }
152
+ const payload = JSON.parse(job.payload);
153
+ const params = { amount: order.amount, currency: order.currency, confirm: true,
154
+ payment_method_data: { type: 'crypto' }, payment_method_types: ['crypto'],
155
+ payment_method_options: { crypto: { mode: 'transaction_verification', transaction_verification_options: { network: job.network, transaction_hash: reference } } },
156
+ metadata: { homie: 'purchase-v1', order: order.id, mpp_challenge_id: payload.challengeId, mpp_method: job.network === 'base' ? 'evm' : 'tempo' } };
157
+ await saveRecording(env, order, params, { idempotencyKey: reference });
158
+ await env.DB.prepare("UPDATE purchase_settlements SET state = 'complete', reference = ?2, error = NULL, updated_at = ?3 WHERE id = ?1").bind(job.id, reference, Date.now()).run();
159
+ } catch (error) {
160
+ await env.DB.prepare("UPDATE purchase_settlements SET error = ?2, updated_at = ?3, state = CASE WHEN ?4 THEN 'manual_review' ELSE state END WHERE id = ?1").bind(job.id, String(error.message).slice(0,500), Date.now(), Date.now() - job.created_at >= REPLAY_LIMIT_MS ? 1 : 0).run();
161
+ }
162
+ }
163
+ }
@@ -0,0 +1,19 @@
1
+ /** Explicit sandbox readiness uses the same read-only facts as scheduled readiness. */
2
+ import { modeOf } from './stripe.mjs';
3
+ import { probeMachinePayments } from './payment-capabilities.mjs';
4
+ import { digest } from './purchase-crypto.mjs';
5
+ export async function testPaymentExchange(env) {
6
+ if (modeOf(env.STRIPE_KEY) !== 'test') throw new Error('The sandbox readiness check requires a sandbox key');
7
+ const key = await digest(env.STRIPE_KEY);
8
+ const row = await env.DB.prepare("SELECT value FROM meta WHERE key = 'purchase-test-exchange'").first();
9
+ const prior = row ? JSON.parse(row.value) : null;
10
+ if (prior?.key === key && prior.nextCheckAt > Date.now()) return prior;
11
+ const observed = await probeMachinePayments(env);
12
+ const verified = Object.values(observed.machine).some(Boolean);
13
+ const report = { key, checkedAt: Date.now(), verified,
14
+ nextCheckAt: Date.now() + (verified ? 6 * 3600000 : 300000),
15
+ reason: verified ? null : Object.values(observed.reasons).join('; '),
16
+ scope: 'Read-only sandbox configuration check. No sale, PaymentIntent, entitlement or refund was created. Write permissions and real settlement remain unverified.' };
17
+ await env.DB.prepare("INSERT OR REPLACE INTO meta (key,value) VALUES ('purchase-test-exchange',?1)").bind(JSON.stringify(report)).run();
18
+ return report;
19
+ }
@@ -0,0 +1,140 @@
1
+ /** Provider facts drive orders. Offers and entitlements are immutable purchase inputs. */
2
+ import { canonicalJson } from "./referrals.mjs";
3
+ import { digest } from "./purchase-crypto.mjs";
4
+
5
+
6
+ export const states = Object.freeze(['started','paid','fulfilled','refunded','disputed','lapsed','expired','failed','lost']);
7
+ export const transitions = Object.freeze({
8
+ started: ['paid','refunded','expired','failed','lapsed'],
9
+ paid: ['fulfilled','refunded','disputed','lapsed','lost'],
10
+ fulfilled: ['refunded','disputed','lapsed','lost'],
11
+ refunded: [],
12
+ disputed: ['paid','fulfilled','refunded','lapsed','lost'],
13
+ lapsed: ['paid','refunded','disputed'],
14
+ expired: ['paid','refunded'], failed: ['paid','refunded'], lost: ['paid','refunded'],
15
+ });
16
+ export function transition(from, to, { renewal = false, refundReversal = false } = {}) {
17
+ if ((renewal || refundReversal) && from === "refunded" && to === "paid") return to;
18
+ if (
19
+ !states.includes(from) ||
20
+ !states.includes(to) ||
21
+ (from !== to && !transitions[from].includes(to))
22
+ )
23
+ throw new Error(`Illegal purchase transition: ${from} -> ${to}`);
24
+ return to;
25
+ }
26
+ export async function offerVersion(resource, terms) {
27
+ const offer = { resource, terms };
28
+ return { ...offer, version: await digest(canonicalJson(offer)) };
29
+ }
30
+ export function entitlement(order, offer) {
31
+ if (order.offerVersion !== offer.version)
32
+ throw new Error("Order and offer version differ");
33
+ return {
34
+ order: order.id,
35
+ buyer: order.buyer,
36
+ resource: offer.resource,
37
+ terms: structuredClone(offer.terms),
38
+ active: ["paid", "fulfilled", "disputed"].includes(order.state),
39
+ };
40
+ }
41
+
42
+ /** D1 compare-and-swap gives mppx its required shared atomic replay store. */
43
+ export function paymentStore(db, mode) {
44
+ const keyOf = (key) => `payment:${mode}:${key}`;
45
+ return {
46
+ async get(key) {
47
+ const row = await db
48
+ .prepare("SELECT value FROM meta WHERE key = ?1")
49
+ .bind(keyOf(key))
50
+ .first();
51
+ return row ? JSON.parse(row.value).value : null;
52
+ },
53
+ async put(key, value) {
54
+ await this.update(key, () => ({ op: "set", value, result: undefined }));
55
+ },
56
+ async delete(key) {
57
+ await this.update(key, () => ({ op: "delete", result: undefined }));
58
+ },
59
+ async update(key, fn) {
60
+ const k = keyOf(key);
61
+ for (let attempt = 0; attempt < 32; attempt++) {
62
+ const row = await db
63
+ .prepare("SELECT value FROM meta WHERE key = ?1")
64
+ .bind(k)
65
+ .first();
66
+ const change = fn(row ? JSON.parse(row.value).value : null);
67
+ if (change.op === "noop") return change.result;
68
+ // Tombstones retain a unique revision, avoiding an ABA race on deletion.
69
+ const value = JSON.stringify({
70
+ revision: crypto.randomUUID(),
71
+ value: change.op === "delete" ? null : change.value,
72
+ });
73
+ const result = row
74
+ ? await db
75
+ .prepare(
76
+ "UPDATE meta SET value = ?2 WHERE key = ?1 AND value = ?3",
77
+ )
78
+ .bind(k, value, row.value)
79
+ .run()
80
+ : await db
81
+ .prepare("INSERT OR IGNORE INTO meta (key,value) VALUES (?1,?2)")
82
+ .bind(k, value)
83
+ .run();
84
+ if (result.meta.changes === 1) return change.result;
85
+ }
86
+ throw new Error("Payment state busy; retry the same request");
87
+ },
88
+ };
89
+ }
90
+
91
+ /** The purchase table is the sole lifecycle record. Provider facts use this writer only. */
92
+ export async function orderState(env, id) {
93
+ const order = await env.DB.prepare("SELECT * FROM purchase_orders WHERE id = ?1").bind(id).first();
94
+ return order && {...order, offerVersion:order.offer_version, state:order.status};
95
+ }
96
+ const mutable = new Set(['customer','subscription','paid_until','quantity','session','checkout_url','claim_hash','buyer','payment','tax','total','paid_at','refunded_at','refund','dispute','fulfilled_at','note']);
97
+ export function orderFact(env, id, fact, fields = {}, { renewal = false, refundReversal = false } = {}) {
98
+ const target = fact === 'payment' ? 'paid' : fact;
99
+ if (fact !== 'details' && !states.includes(target)) throw new Error(`Unknown purchase fact: ${fact}`);
100
+ const allowed = fact === 'details' ? [] : states.filter((from) => {
101
+ try { transition(from,target,{renewal,refundReversal}); return true; } catch { return false; }
102
+ });
103
+ // Expiry/failure cannot revoke money already accepted. Re-reading the same
104
+ // payment preserves delivery and disputes; only reconciliation resolves them.
105
+ const sources = factSources(fact, allowed);
106
+ const status = fact === 'details' ? 'status' : `CASE ${target === 'paid' ? "WHEN status = 'fulfilled' THEN status " : ''}${target === 'fulfilled' ? "WHEN status = 'disputed' THEN status " : ''}WHEN status IN (${sources.map((v)=>`'${v}'`).join(',')}) THEN '${target}' ELSE status END`;
107
+ const values = [id, Date.now()];
108
+ const sets = [`status = ${status}`, 'updated_at = ?2'];
109
+ if (target === 'paid' && fields.paid_at === undefined) fields = {...fields,paid_at:Date.now()};
110
+ if (target === 'fulfilled') fields = {...fields,fulfilled_at:Date.now()};
111
+ if (target === 'refunded' && fields.refunded_at === undefined) fields = {...fields,refunded_at:Date.now()};
112
+ for (const [key,value] of Object.entries(fields)) {
113
+ if (!mutable.has(key)) throw new Error(`Immutable purchase field: ${key}`);
114
+ values.push(value ?? null);
115
+ const parameter = `?${values.length}`;
116
+ sets.push(`${key} = ${['paid_at','fulfilled_at'].includes(key) || key === 'payment' && !renewal ? `COALESCE(${key},${parameter})` : parameter}`);
117
+ }
118
+ return env.DB.prepare(`UPDATE purchase_orders SET ${sets.join(', ')} WHERE id = ?1`).bind(...values);
119
+ }
120
+ export async function recordOrderState(env, order, fact, fields, options) {
121
+ await orderFact(env,order.id,fact,fields,options).run();
122
+ const current = await orderState(env,order.id);
123
+ return current && { ...current, factResult: { fact, applied: fact === 'details' || current.status === (fact === 'payment' ? 'paid' : fact), state: current.status } };
124
+ }
125
+
126
+ export function factSources(fact, allowed) {
127
+ return fact === 'payment' ? ['started','expired','failed'] : ['expired','failed'].includes(fact) ? ['started'] : allowed;
128
+ }
129
+ export function factState(from, fact, options = {}) {
130
+ if (fact === 'details') return from;
131
+ const target = fact === 'payment' ? 'paid' : fact;
132
+ const allowed = states.filter((state) => { try { transition(state, target, options); return true; } catch { return false; } });
133
+ if (target === 'paid' && from === 'fulfilled' || target === 'fulfilled' && from === 'disputed') return from;
134
+ return factSources(fact, allowed).includes(from) ? target : from;
135
+ }
136
+
137
+ export const releaseClaim = (env, id) => env.DB.prepare("UPDATE purchase_orders SET claim_hash = ?2 WHERE id = ?1 AND payment IS NULL AND paid_at IS NULL").bind(id, `released:${id}`);
138
+
139
+ /** Atomically reserve the first hosted session before processing its payment. */
140
+ export const bindHostedSession = (env,id,session) => env.DB.prepare('UPDATE purchase_orders SET session = ?2 WHERE id = ?1 AND (session IS NULL OR session = ?2)').bind(id,session).run();
@@ -0,0 +1,17 @@
1
+ import { canonicalJson } from "./referrals.mjs";
2
+ export const GRANT_TYPE = 'homie-purchase-grant+jwt';
3
+ export const PROOF_TYPE = 'homie-purchase-proof+jwt';
4
+ export const PROOF_AUDIENCE = 'homie-purchase-evidence';
5
+ export const GRANT_AUDIENCE = 'homie-purchases';
6
+ export const digest = async (value) => [...new Uint8Array(await crypto.subtle.digest('SHA-256', typeof value === 'string' ? new TextEncoder().encode(value) : value))].map((b) => b.toString(16).padStart(2, '0')).join('');
7
+ export const manifestHash = (resource) => digest(canonicalJson(resource));
8
+ export async function signGrant(key, claims, proof = false) {
9
+ const { calculateJwkThumbprint, SignJWT } = await import('jose');
10
+ return new SignJWT(claims).setProtectedHeader({ alg: 'Ed25519', typ: proof ? PROOF_TYPE : GRANT_TYPE, kid: await calculateJwkThumbprint(key.publicJwk) }).sign(key.privateKey);
11
+ }
12
+ export async function verifyGrant(token, jwk, { issuer, subject, resource, kind, version, proof = false, audience = proof ? PROOF_AUDIENCE : GRANT_AUDIENCE } = {}) {
13
+ const { calculateJwkThumbprint, importJWK, jwtVerify } = await import('jose');
14
+ const { payload, protectedHeader } = await jwtVerify(token, await importJWK(jwk, 'Ed25519'), { algorithms: ['Ed25519'], typ: proof ? PROOF_TYPE : GRANT_TYPE, issuer, audience, ...(subject ? { subject } : {}), requiredClaims: ['iss', 'aud', 'sub', 'iat', 'jti', 'resource', 'kind', 'version', ...(proof && audience !== PROOF_AUDIENCE ? [] : ['manifest']), 'mode', ...(proof ? [] : ['exp'])] });
15
+ if (protectedHeader.kid !== await calculateJwkThumbprint(jwk) || (resource && payload.resource !== resource) || (kind && payload.kind !== kind) || (version && payload.version !== version)) throw new Error('The purchase proof is for another key or release');
16
+ return payload;
17
+ }
@@ -0,0 +1,108 @@
1
+ /** Studio licences use the shop's Stripe, order book, webhook and refund office. */
2
+ import {
3
+ orderState,
4
+ recordOrderState,
5
+ entitlement,
6
+ } from "./purchase-core.mjs";
7
+
8
+ import { signingKey, publicKeys } from "./purchase-keys.mjs";
9
+
10
+ import { GRANT_AUDIENCE, PROOF_AUDIENCE, manifestHash, signGrant, verifyGrant } from "./purchase-crypto.mjs";
11
+ import { resourceKind } from "./resource-kinds.mjs";
12
+
13
+ import {
14
+ json,
15
+ fail,
16
+ byId,
17
+ bearer,
18
+ purchasedResource,
19
+ active,
20
+ privateRelease,
21
+ coveredPurchase,
22
+ deliveryAllowed,
23
+ resourceAvailable,
24
+ } from "./purchase-store.mjs";
25
+ export async function grantPurchase(env, origin, o, b = {}) {
26
+ const bought = purchasedResource(o);
27
+ const version = b.version ?? bought.version;
28
+ const p =
29
+ version === bought.version
30
+ ? JSON.parse(o.manifest)
31
+ : await privateRelease(env, o.resource_kind, bought.id, version);
32
+ if (!p)
33
+ return fail(503, "Release temporarily unavailable; retry this purchase");
34
+ if (!(await coveredPurchase(env, origin, o, p)))
35
+ return fail(
36
+ 403,
37
+ "This version needs a new licence and a fresh price approval",
38
+ );
39
+ if (!(await resourceAvailable(env, o.resource_kind, p)))
40
+ return fail(503, o.payment ? 'Paid file bytes are temporarily unavailable. The seller must restore storage. Do not pay again.' : 'Your chain payment settled but Stripe has not recorded it. Files are temporarily unavailable. Contact the seller for restoration or a manual refund to your original payer address. Do not pay again.');
41
+ if (!active(o)) return fail(402, `Purchase is ${o.status}; delivery is unavailable`);
42
+ await env.DB.prepare(
43
+ "INSERT OR IGNORE INTO purchase_grants (order_id,version,manifest_hash) VALUES (?1,?2,?3)",
44
+ )
45
+ .bind(o.id, p.version, await manifestHash(p))
46
+ .run();
47
+ const coreOrder = await recordOrderState(env, o, "fulfilled");
48
+ const offerRow =
49
+ coreOrder.offerVersion &&
50
+ (await env.DB.prepare("SELECT value FROM purchase_offers WHERE version = ?1")
51
+ .bind(coreOrder.offerVersion)
52
+ .first());
53
+ const rights = offerRow
54
+ ? entitlement(coreOrder, JSON.parse(offerRow.value))
55
+ : null;
56
+ if (!rights || !rights.active)
57
+ return fail(402, `Purchase is ${coreOrder.state}; delivery is unavailable`);
58
+ const key = await signingKey(env);
59
+ const now = Math.floor(Date.now() / 1000);
60
+ const claims = {
61
+ iss: origin,
62
+ aud: GRANT_AUDIENCE,
63
+ sub: o.buyer,
64
+ studio: o.buyer_label,
65
+ offer: o.offer ? JSON.parse(o.offer) : bought.sale,
66
+ jti: o.id,
67
+ iat: now,
68
+ claimVersion: o.claim_hash,
69
+ resource: p.id,
70
+ kind: o.resource_kind,
71
+ version: p.version,
72
+ manifest: await manifestHash(p),
73
+ terms: resourceKind(o.resource_kind).terms(bought),
74
+ quantity: o.quantity,
75
+ game: o.game,
76
+ mode: o.mode,
77
+ paidUntil: o.paid_until,
78
+ billing: bought.sale.billing,
79
+ onRefund: bought.sale.onRefund,
80
+ onExpiry: bought.sale.onExpiry,
81
+ };
82
+ return json({
83
+ ok: true,
84
+ entitlement: rights,
85
+ token: await signGrant(key, { ...claims, exp: now + 300 }),
86
+ proof: await signGrant(key, { ...claims, aud: PROOF_AUDIENCE }, true),
87
+ hubProof:
88
+ b.audience && /^https:\/\/[^/]+$/.test(b.audience)
89
+ ? await signGrant(
90
+ key,
91
+ {
92
+ iss: claims.iss,
93
+ aud: b.audience,
94
+ sub: claims.sub,
95
+ jti: claims.jti,
96
+ iat: claims.iat,
97
+ resource: claims.resource,
98
+ kind: claims.kind,
99
+ version: claims.version,
100
+ mode: claims.mode,
101
+ },
102
+ true,
103
+ )
104
+ : undefined,
105
+ manifest: p,
106
+ paidUntil: o.paid_until,
107
+ });
108
+ }