@homie-rocks/studio 0.37.0 → 0.37.2

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 (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/homie-studio.mjs +10 -3
  3. package/lib/cloudflare.mjs +40 -6
  4. package/lib/dev.mjs +1 -1
  5. package/lib/doctor.mjs +7 -7
  6. package/lib/domain.mjs +43 -0
  7. package/lib/jobs.mjs +17 -7
  8. package/lib/mcp-tools.mjs +49 -18
  9. package/lib/mcp.mjs +6 -11
  10. package/lib/parts-build.mjs +23 -4
  11. package/lib/parts-cli.mjs +54 -3
  12. package/lib/parts-purchase.mjs +174 -0
  13. package/lib/parts-store.mjs +50 -19
  14. package/lib/parts-tools.mjs +15 -12
  15. package/lib/parts-upload.mjs +43 -0
  16. package/lib/parts.mjs +49 -5
  17. package/lib/prepare.mjs +47 -0
  18. package/lib/purchase-links.mjs +42 -0
  19. package/lib/purchase-wallet.mjs +114 -0
  20. package/lib/scaffold.mjs +35 -5
  21. package/lib/shoot.mjs +1 -1
  22. package/lib/shop-links.mjs +15 -6
  23. package/lib/shop.mjs +91 -21
  24. package/lib/stripe-connect.mjs +2 -2
  25. package/lib/template-history.json +82 -0
  26. package/package.json +12 -2
  27. package/parts/COMMERCIAL-LICENCE.md +21 -0
  28. package/parts/PARTS.md +238 -10
  29. package/worker/extensions.mjs +13 -0
  30. package/worker/index.mjs +3 -2
  31. package/worker/machine-payments.mjs +72 -0
  32. package/worker/office.mjs +4 -1
  33. package/worker/parts-protected.mjs +111 -0
  34. package/worker/parts-purchase-catalogue.mjs +34 -0
  35. package/worker/parts-resource.mjs +32 -0
  36. package/worker/parts-sale.mjs +59 -0
  37. package/worker/parts.mjs +24 -11
  38. package/worker/payment-capabilities.mjs +70 -0
  39. package/worker/payment-dry-exchange.mjs +30 -0
  40. package/worker/payment-manual-refund.mjs +40 -0
  41. package/worker/payment-policy.mjs +40 -0
  42. package/worker/payment-recovery.mjs +163 -0
  43. package/worker/payment-test-exchange.mjs +19 -0
  44. package/worker/purchase-core.mjs +140 -0
  45. package/worker/purchase-crypto.mjs +17 -0
  46. package/worker/purchase-delivery.mjs +108 -0
  47. package/worker/purchase-discovery.mjs +26 -0
  48. package/worker/purchase-hosted-readiness.mjs +16 -0
  49. package/worker/purchase-keys.mjs +20 -0
  50. package/worker/purchase-links.mjs +9 -0
  51. package/worker/purchase-machine.mjs +279 -0
  52. package/worker/purchase-mcp.mjs +42 -0
  53. package/worker/purchase-office-routes.mjs +17 -0
  54. package/worker/purchase-office-script.mjs +38 -0
  55. package/worker/purchase-office.mjs +35 -0
  56. package/worker/purchase-owner.mjs +243 -0
  57. package/worker/purchase-pricing.mjs +8 -0
  58. package/worker/purchase-problem.mjs +4 -0
  59. package/worker/purchase-reconciliation.mjs +582 -0
  60. package/worker/purchase-response.mjs +82 -0
  61. package/worker/purchase-routes.mjs +611 -0
  62. package/worker/purchase-schema.mjs +50 -0
  63. package/worker/purchase-store.mjs +148 -0
  64. package/worker/purchases.mjs +6 -0
  65. package/worker/resource-kinds.mjs +20 -0
  66. package/worker/selling/index.mjs +8 -0
  67. package/worker/selling/runtime.mjs +42 -0
  68. package/worker/settlement-journal.mjs +138 -0
  69. package/worker/shop-page.mjs +7 -4
  70. package/worker/shop-refunds.mjs +22 -0
  71. package/worker/shop-rules.mjs +1 -1
  72. package/worker/shop.mjs +18 -18
  73. package/worker/stripe.mjs +36 -3
  74. package/worker/version.mjs +1 -1
@@ -0,0 +1,59 @@
1
+ import { amountError } from './shop-rules.mjs';
2
+ import { digest, manifestHash } from "./purchase-crypto.mjs";
3
+ /** Paid-part metadata shared by the builder, buyer and Worker. No payment policy ceilings. */
4
+ import { canonicalJson } from './referrals.mjs';
5
+
6
+ export const quoteHash = async (part, quantity = 1, game = null, offer = part.sale) => digest(canonicalJson({ manifest: await manifestHash(part), part: part.id, release: part.version, scope: offer.scope, quantity, game, interval: offer.billing, currency: offer.currency, amount: offer.amount * quantity, offer }));
7
+ export const legalTerms = (p) => ({ license: p.license, terms: p.files?.find((f) => f.path === p.licenseTerms)?.sha256, scope: p.sale?.scope, source: p.sale?.source, commercialUse: p.sale?.commercialUse, transferable: p.sale?.transferable, onRefund: p.sale?.onRefund, onExpiry: p.sale?.onExpiry, refund: p.sale?.refund, updates: p.sale?.updates });
8
+ export const sameTerms = (a, b) => canonicalJson(legalTerms(a)) === canonicalJson(legalTerms(b));
9
+ export const publicFiles = (p) => new Set([p.preview?.page, p.preview?.image, p.licenseTerms, ...(p.files ?? []).filter((f) => /^LICENSES\/[^/]+\.txt$/.test(f.path)).map((f) => f.path), ...(p.sale?.publicFiles ?? [])].filter(Boolean));
10
+ export const objectKey = (hash) => `paid-parts/files/${hash}`;
11
+ export const releaseKey = (id, version) => `paid-parts/releases/${id}/${version}.json`;
12
+
13
+ export function saleProblems(p) {
14
+ if (p.sale === undefined) return [];
15
+ const s = p.sale; const errors = [];
16
+ if (!s || typeof s !== 'object' || Array.isArray(s)) return ['sale must be an object'];
17
+ const invalidAmount = amountError(s.amount, s.currency);
18
+ if (invalidAmount) errors.push(invalidAmount);
19
+ if (!Intl.supportedValuesOf('currency').map((v) => v.toLowerCase()).includes(s.currency) || /^(xau|xag|xpt|xpd|xxx|xts)$/.test(s.currency)) errors.push('currency must be a lower-case ISO currency code');
20
+ if (s.taxBehavior !== undefined && !['inclusive', 'exclusive', 'unspecified'].includes(s.taxBehavior)) errors.push('taxBehavior must be inclusive, exclusive or unspecified');
21
+ for (const key of ['immediateDelivery', 'automaticTax', 'adaptivePricing', 'promotionCodes', 'refundEndsSubscription', 'invoiceCreation', 'customerCreation', 'taxIdCollection']) if (s[key] !== undefined && typeof s[key] !== 'boolean') errors.push(`${key} must be true or false`);
22
+ if (s.refundWindowDays !== undefined && (!Number.isFinite(s.refundWindowDays) || s.refundWindowDays < 0)) errors.push('refundWindowDays must be a non-negative finite number');
23
+ if (s.renewalGraceMinutes !== undefined && (!Number.isFinite(s.renewalGraceMinutes) || s.renewalGraceMinutes < 0)) errors.push('renewalGraceMinutes must be a non-negative finite number');
24
+ if (s.intentMinutes !== undefined && (!Number.isFinite(s.intentMinutes) || s.intentMinutes <= 0)) errors.push('intentMinutes must be a positive finite number');
25
+ for (const [key, values] of Object.entries({ billing: ['one-time', 'day', 'week', 'month', 'year'], updates: ['none', 'major', 'all'], onRefund: ['retain', 'terminate'], onExpiry: ['retain', 'terminate'] })) if (!values.includes(s[key])) errors.push(`${key} must be ${values.join(', ')}`);
26
+ if (typeof s.scope !== 'string' || !s.scope.trim()) errors.push('scope must describe the licensed use');
27
+ for (const key of ['source', 'commercialUse', 'transferable']) if (typeof s[key] !== 'boolean') errors.push(`${key} must be true or false`);
28
+ if (typeof s.refund !== 'string' || !s.refund.trim()) errors.push('refund must state the seller refund terms');
29
+ if ((s.taxCode !== undefined || s.automaticTax === true) && !/^txcd_\d{8}$/.test(s.taxCode ?? '')) errors.push('taxCode must name the Stripe product tax code');
30
+ if (!p.licenseTerms || !p.files?.some((f) => f.path === p.licenseTerms)) errors.push('licenseTerms must name a hashed file containing the full licence');
31
+ if (s.publicFiles !== undefined && (!Array.isArray(s.publicFiles) || s.publicFiles.some((path) => !p.files?.some((f) => f.path === path)))) errors.push('publicFiles must list only hashed files deliberately offered before purchase');
32
+ return errors;
33
+ }
34
+
35
+ export function coversRelease(bought, wanted) {
36
+ if (bought.id !== wanted.id) return false;
37
+ if (bought.version === wanted.version) return true;
38
+ if (bought.version.includes('-') || wanted.version.includes('-')) return false;
39
+ if (compareVersions(wanted.version, bought.version) < 0) return false;
40
+ return bought.sale.updates === 'all' || (bought.sale.updates === 'major' && wanted.version.split('.')[0] === bought.version.split('.')[0]);
41
+ }
42
+
43
+ /** Semver precedence, including numeric versus text prerelease identifiers. */
44
+ export function compareVersions(a, b) {
45
+ const parse = (v) => { const dash = v.indexOf('-'); const core = dash < 0 ? v : v.slice(0, dash); const pre = dash < 0 ? undefined : v.slice(dash + 1); return { core: core.split('.').map(Number), pre: pre?.split('.') }; };
46
+ const left = parse(a); const right = parse(b);
47
+ for (let i = 0; i < 3; i++) if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
48
+ if (!left.pre || !right.pre) return left.pre ? -1 : right.pre ? 1 : 0;
49
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i++) {
50
+ const x = left.pre[i]; const y = right.pre[i];
51
+ if (x === y) continue;
52
+ if (x === undefined || y === undefined) return x === undefined ? -1 : 1;
53
+ const nx = /^\d+$/.test(x); const ny = /^\d+$/.test(y);
54
+ if (nx && ny) return Number(x) < Number(y) ? -1 : 1;
55
+ if (nx !== ny) return nx ? -1 : 1;
56
+ return x < y ? -1 : 1;
57
+ }
58
+ return 0;
59
+ }
package/worker/parts.mjs CHANGED
@@ -1,3 +1,5 @@
1
+ import { selling } from './extensions.mjs';
2
+
1
3
  /**
2
4
  * THE STUDIO'S SHARED GAME PARTS, AS ITS SITE SERVES THEM (parts/PARTS.md section 4).
3
5
  *
@@ -17,7 +19,7 @@ const VERSION = /^\d{1,6}\.\d{1,6}\.\d{1,6}(?:-[0-9A-Za-z.-]{1,40})?$/;
17
19
  /** A packed part's files: framed by the part's own page and by the hub, so they keep their own frame rule (index.mjs finish). */
18
20
  export const PART_FILES = /^\/parts\/[a-z0-9][a-z0-9-]{0,47}\/\d{1,6}\.\d{1,6}\.\d{1,6}[^/]*\//;
19
21
  export const PARTS_WELL_KNOWN = '/.well-known/homie-parts.json';
20
- export const isPartsPath = (path) => path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
22
+ export const isPartsPath = (path) => path === '/openapi.json' || path === '/.well-known/api-catalog' || path === '/parts/catalog.json' || path === '/purchases/keys.json' || path === PARTS_WELL_KNOWN || path === '/parts' || path.startsWith('/parts/');
21
23
 
22
24
  const CORS = { 'access-control-allow-origin': '*', 'x-content-type-options': 'nosniff' };
23
25
  const json = (body, status = 200, headers = {}) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8', ...headers } });
@@ -37,25 +39,29 @@ export async function partsIndex(env, origin) {
37
39
  * One index entry as the world sees it: how to add it, and the game it came out of with its page and Play address.
38
40
  * Only a PUBLIC game is named (a private or invite-only game's parts are still shared, without saying where from).
39
41
  */
40
- function entryOf(p, cat, url) {
42
+ function entryOf(p, cat, url, capabilities = { machine: {} }) {
41
43
  const g = (p.from?.game ?? p.from?.app) ? (cat.games ?? []).find((x) => x.id === (p.from.game ?? p.from.app)) : null;
42
44
  const { from, ...rest } = p;
43
45
  return {
44
46
  ...rest,
45
- ...(!g && from && (from.music || from.video) ? { from: { ...(from.music ? { music: from.music } : { video: from.video }), name: from.name ?? null, studio: from.studio ?? null } } : {}),
47
+ ...(!g && from && !from.game && !from.app ? { from } : {}),
46
48
  ...(g ? { from: { [g.kind === 'app' ? 'app' : 'game']: g.id, name: g.name ?? g.id, studio: cat.studio?.name ?? from.studio ?? null, page: `${url.origin}/${g.id}/`, [g.kind === 'app' ? 'open' : 'play']: `${url.origin}/${g.id}/${g.kind === 'app' ? 'open' : 'play'}` } } : {}),
47
49
  add: `${url.host}/${p.id}`,
50
+ ...(selling.entryDescription?.(p, cat, url, capabilities) ?? {}),
48
51
  };
49
52
  }
50
53
 
51
54
  export async function partsRoutes(request, env, url, { catalogueOf }) {
52
55
  const path = url.pathname;
56
+ if (!selling.enabled && ['/purchases/keys.json','/openapi.json','/.well-known/api-catalog'].includes(path)) return null;
57
+ if (selling.enabled) { const extra = await selling.discoveryRoutes(request, env, url, { catalogueOf }); if (extra) return extra; }
53
58
  const read = request.method === 'GET' || request.method === 'HEAD';
54
- if (path === PARTS_WELL_KNOWN) {
59
+ if (path === PARTS_WELL_KNOWN || path === '/parts/catalog.json') {
55
60
  if (request.method === 'OPTIONS') return new Response(null, { status: 204, headers: { ...CORS, 'access-control-allow-methods': 'GET, HEAD' } });
56
61
  if (!read) return json({ ok: false, error: 'method' }, 405, { allow: 'GET, HEAD', ...CORS });
57
62
  const [index, cat] = await Promise.all([partsIndex(env, url.origin), catalogueOf()]);
58
- return json({ v: 1, studio: { name: cat.studio?.name ?? index.studio?.name ?? null, url: url.origin }, parts: index.parts.map((p) => entryOf(p, cat, url)) }, 200, { ...CORS, 'cache-control': 'public, max-age=300' });
63
+ const capabilities = await selling.machineCapabilities(env);
64
+ return json({ v: 1, studio: { name: cat.studio?.name ?? index.studio?.name ?? null, url: url.origin }, parts: index.parts.map((p) => entryOf(p, cat, url, capabilities)) }, 200, { ...CORS, 'cache-control': 'public, max-age=300' });
59
65
  }
60
66
  const segs = path.split('/').filter(Boolean);
61
67
  // /parts/<id>/<version>/<file…>: a packed file, only when the index names that part and that version.
@@ -67,6 +73,9 @@ export async function partsRoutes(request, env, url, { catalogueOf }) {
67
73
  let file;
68
74
  try { file = segs.slice(3).map(decodeURIComponent).join('/'); } catch { file = '..'; }
69
75
  const gone = () => new Response('not found', { status: 404, headers: { ...CORS, 'cache-control': 'no-store' } });
76
+ if (file.split('/').some((s) => !s || s === '..' || s.startsWith('.')) || file.includes('\\')) return gone();
77
+ const protectedFile = part && !part.sale && !part.releases?.some((r) => r.version === segs[2] && r.sale) ? null : await selling.paidFile(request, env, url, segs[1], segs[2], file, TYPES[file.split('.').pop().toLowerCase()]);
78
+ if (protectedFile) return protectedFile;
70
79
  if (!part || !(part.versions ?? []).includes(segs[2]) || file.split('/').some((s) => !s || s === '..' || s.startsWith('.'))) return gone();
71
80
  const res = await env.ASSETS.fetch(new Request(`${url.origin}/parts/${segs[1]}/${segs[2]}/${file.split('/').map(encodeURIComponent).join('/')}`));
72
81
  if (!res.ok) return gone();
@@ -104,7 +113,7 @@ export async function partsRoutes(request, env, url, { catalogueOf }) {
104
113
  /* ------------------------------------------------------------------ the pages */
105
114
 
106
115
  const KIND_WORDS = { character: 'Character', rig: 'Rig', clips: 'Animation clips', environment: 'Environment', effect: 'Effect', sound: 'Sound', ui: 'UI', mechanic: 'Mechanic', shader: 'Shader', 'level-generator': 'Level generator', 'bot-brain': 'Bot brain', 'audio-pack': 'Audio pack', 'set-piece': 'Set piece' };
107
- const kindOf = (p) => KIND_WORDS[p.kind] ?? 'Part';
116
+ const kindOf = (p) => KIND_WORDS[p.kind] ?? p.kind ?? 'Part';
108
117
  const CSS = `<style>
109
118
  .pgrid{display:grid;grid-template-columns:repeat(auto-fill,minmax(260px,1fr));gap:18px;margin:8px 0 0;padding:0;list-style:none}
110
119
  .pcard{display:flex;flex-direction:column;gap:8px;padding:18px;border:1px solid color-mix(in srgb,var(--fg) 14%,transparent);border-radius:16px;text-decoration:none;color:inherit;background:color-mix(in srgb,var(--fg) 3%,transparent)}
@@ -134,12 +143,12 @@ const head = (kicker, title, lead, extra = '') => `<header class="head"><p class
134
143
  const studioOf = (cat) => cat.studio?.name ?? 'Studio';
135
144
  const fromLine = (p) => ((p.from?.game ?? p.from?.app) ? `From <a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a>` : '');
136
145
  /** What a person says to their AI to get the part, in plain words with its name and reference: no command to type. */
137
- const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my game`;
146
+ const sayAdd = (p) => `Add the "${p.name}" part from ${p.add} to my studio`;
138
147
  const sayRow = (label, say) => `<div class="psay"><div><span class="ptag">${esc(label)}</span><code>${esc(say)}</code></div><button class="pcopy" type="button" data-copy="${esc(say)}">${icon('copy')}<span data-copy-word>Copy</span></button></div>`;
139
148
 
140
149
  function card(p) {
141
150
  const img = p.preview?.image ? `<img src="${esc(`${p.url}${p.preview.image}`)}" alt="" loading="lazy">` : '';
142
- return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
151
+ return `<li><a class="pcard" href="/parts/${esc(p.id)}/">${img}<span class="ptag">${esc(kindOf(p))} · ${esc(p.license ?? '')} · ${esc(selling.priceWords(p.sale))}</span><h3>${esc(p.name)}</h3><p>${esc(p.summary ?? '')}</p>${(p.from?.game ?? p.from?.app) ? `<p>From ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</p>` : ''}</a></li>`;
143
152
  }
144
153
 
145
154
  export function partsPage(cat, parts, url) {
@@ -149,7 +158,7 @@ export function partsPage(cat, parts, url) {
149
158
  for (const p of parts) { const k = (p.from?.game ?? p.from?.app) ?? ''; if (!groups.has(k)) groups.set(k, []); groups.get(k).push(p); }
150
159
  const body = [...groups].sort(([a], [b]) => (a === '' ? 1 : b === '' ? -1 : a.localeCompare(b))).map(([k, list]) => `<p class="sec"${k ? ` id="from-${esc(k)}"` : ''}>${k ? `From ${esc(list[0].from.name ?? k)}` : 'More parts'}</p><ul class="pgrid">${list.map(card).join('')}</ul>`).join('');
151
160
  return layout(cat, {
152
- title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS,
161
+ title: `Game parts · ${name}`, description: `Pieces of ${name}'s games that other studios can use in their own: creatures, levels, mechanics, sounds. Each with its licence.`, origin: url.origin, path: '/parts/', page: 'parts', head: CSS + (selling.enabled ? `<script type="application/ld+json">${JSON.stringify({ '@context': 'https://schema.org', '@type': 'ItemList', itemListElement: parts.map((p, i) => ({ '@type': 'ListItem', position: i + 1, item: productDescription(p, url.origin) })) }).replaceAll('<', '\\u003c')}</script>` : ''),
153
162
  main: `${head('Game parts', 'Take a piece, make a game', `Pieces of ${name}'s games, shared one by one. Take the creature from one game, the level from another and the bot brain from a third, and make something new. Each part says its licence and who to credit.`)}
154
163
  <div class="wrap">${body}</div>`,
155
164
  });
@@ -207,6 +216,8 @@ function previewOf(p) {
207
216
  <a class="popen" href="${src}" target="_blank" rel="noopener noreferrer">${icon('arrow')}<span>Open the preview in its own tab</span></a>`;
208
217
  }
209
218
 
219
+ export const productDescription = (p, origin) => selling.productDescription?.(p, origin);
220
+
210
221
  export function partPage(cat, p, url) {
211
222
  const name = studioOf(cat);
212
223
  const preview = p.preview?.page
@@ -216,6 +227,8 @@ export function partPage(cat, p, url) {
216
227
  ['Kind', esc(kindOf(p))],
217
228
  p.uses?.length ? ['For', esc(p.uses.join(', '))] : null,
218
229
  (p.from?.game ?? p.from?.app) ? [p.from.app ? 'From the app' : 'From the game', `<a href="/${esc((p.from.game ?? p.from.app))}/">${esc(p.from.name ?? (p.from.game ?? p.from.app))}</a> · <a href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${p.from.app ? 'Open it' : 'Play it'}</a>`] : null,
230
+ ['Price', esc(selling.priceWords(p.sale))],
231
+ p.sale ? ['Purchase terms', esc(`${p.sale.refund} Source: ${p.sale.source ? 'included' : 'not included'}. Updates: ${p.sale.updates}.`)] : null,
219
232
  ['Licence', `${esc(p.license ?? 'none named')}${p.license && !/^LicenseRef-/.test(p.license) ? ` · <a href="https://spdx.org/licenses/${esc(p.license)}.html" rel="noopener">what it allows</a>` : ''}`],
220
233
  p.attribution ? ['Credit', esc(p.attribution)] : null,
221
234
  ['Version', `${esc(p.version)}${(p.versions ?? []).length > 1 ? ` (also ${esc(p.versions.filter((v) => v !== p.version).join(', '))})` : ''}`],
@@ -228,7 +241,7 @@ export function partPage(cat, p, url) {
228
241
  (p.tags ?? []).length ? ['Tags', esc(p.tags.join(', '))] : null,
229
242
  ].filter(Boolean);
230
243
  return layout(cat, {
231
- title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS,
244
+ title: `${p.name} · a game part from ${name}`, description: `${p.summary ?? ''} A ${kindOf(p).toLowerCase()} from ${name}${p.from?.name ? `'s ${p.from.name}` : ''}, shared under ${p.license ?? 'its own terms'}.`.trim(), origin: url.origin, path: `/parts/${p.id}/`, page: 'part', head: CSS + (selling.enabled ? `<script type="application/ld+json">${JSON.stringify(productDescription(p, url.origin)).replaceAll('<', '\\u003c')}</script>` : ''),
232
245
  image: p.preview?.image ? `${p.url}${p.preview.image}` : null,
233
246
  main: `${head(`${kindOf(p)} · shared part`, p.name, p.summary ?? '', `<div class="keys"><a class="ghost" href="/parts/">${icon('arrow')}<span>All parts</span></a>${(p.from?.game ?? p.from?.app) ? `<a class="ghost" href="/${esc((p.from.game ?? p.from.app))}/${p.from.app ? 'open' : 'play'}">${icon('games')}<span>${p.from.app ? 'Open' : 'Play'} ${esc(p.from.name ?? (p.from.game ?? p.from.app))}</span></a>` : ''}</div>`)}
234
247
  <div class="wrap">
@@ -236,7 +249,7 @@ ${preview ? `<p class="sec">Try it</p>${preview}` : ''}
236
249
  <p class="sec">Use it in your game</p>
237
250
  <p class="plead">In a chat with your Homie studio open, ask for it by name:</p>
238
251
  ${sayRow('Say', sayAdd(p))}
239
- <p class="plead">Your AI fetches the files, checks every one, copies them into your studio and credits ${esc(name)} in your game. They are yours to tune from there.</p>
252
+ <p class="plead">${p.sale ? 'Your AI shows the price and asks first. After your wallet pays, it receives the files,' : 'Your AI fetches the files,'} checks every one, copies them into your studio and credits ${esc(name)} in your game. They are yours to tune from there.</p>
240
253
  <p class="sec">About this part</p>
241
254
  <dl class="pfacts">${facts.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join('')}</dl>
242
255
  </div>`,
@@ -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
+ }