@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
package/CHANGELOG.md CHANGED
@@ -17,6 +17,66 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.38.0] - 2026-10-09
21
+
22
+ **Plugin 0.39.0**
23
+
24
+ Your own character answers as soon as you press, even when the room runs on the server.
25
+
26
+ ### Added
27
+
28
+ - Rules games predict your own movement with the same movement code the host runs. They ease corrections from the host and smoothly draw everyone else. Authors get this through the view library.
29
+ - The build checks that browser movement agrees with the server and stays within its budget.
30
+
31
+ ### Changed
32
+
33
+ - New Gem Rush games use rules plus view, with the server keeping movement, pickups and scores. The canvas, controls, waves and knockback keep their familiar look and play. Coin Dash also uses server movement with prediction.
34
+
35
+ ### Fixed
36
+
37
+ - Rules prediction keeps movement continuous when snapshots replace its catch-up path or its input clock recalibrates, fixing backward steps on delayed, lossy connections.
38
+ - A rules correction that catches up to the current fractional tick uses the next movement preview instead of holding still and jumping when the next whole tick arrives. Snapshots also preserve the drawn catch-up pose when they replace its history. Offset fading is bounded by normal movement speed, so it does not compound a fast knock's catch-up speed.
39
+ - Gem Rush reports its downward Y axis to the rules probe, so control checks judge its actual on-screen direction. Rules views can supply their camera axes through `screenBasis`.
40
+ - Playtest no longer attributes its default timeout duration to a rules game that declares no round duration in `game.json`.
41
+ - Perf checks body presence in the last drawn frame without imposing a hidden 400 ms frame-rate requirement on software rendering.
42
+ - Studio checks accept two replicas when the server hosts the rules. Perf reports and regression guards include both replicas, and choose a replica as the default goal for server rooms.
43
+ - Shoot sends held keys to the game frame and reports a server-only preview without a running room as not playing. Its saved receipt now includes the final verdict.
44
+ - The Game Lab exits after writing its completed report, even when Chrome leaves a pipe open. Coin Dash includes a reproducible Lab take.
45
+ - Control timing starts at the game frame's receipt of a key or completed thumb gesture, judges the first changed frame, collects enough frames on a slow renderer, and measures the round clock between matching observations (including slow screenshots). The response and steering limits are unchanged.
46
+ - The controls check exercises a browser disconnect and late joining in server rooms as well as host takeover in browser rooms. A hold with too little runway is repeated after moving away from the wall, with the same steering requirements.
47
+
48
+ ### Upgrade notes
49
+
50
+ - Existing games keep their own source when you upgrade. Games written the old way keep working.
51
+
52
+ ## [0.37.2] - 2026-10-09
53
+
54
+ **Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
55
+
56
+ Sell game, app, music or video parts on your own studio's site, with your price and licence; another studio's AI can discover, pay for and install them.
57
+
58
+ ### Added
59
+
60
+ - **Your own part shop.** Tell your AI which files to sell, their price and their licence. On a studio already deployed with Stripe, connection takes one browser approval when needed (zero if already connected); the AI creates Payment Links and the webhook in your account. It generates and installs the purchase signing key too. Nobody finds or pastes a key for this setup.
61
+ - **An AI can pay without a checkout page.** MPP with Stripe shared payment tokens, x402 on Base, and MPP over MCP return verified files and a signed proof. The buyer approves the exact price or gives its wallet standing authority. Wallet-required approvals still apply. Stripe's hosted checkout is the fallback.
62
+ - **Machine payments are an optional fuller connection.** Stripe's shared-token charge and stablecoin PaymentIntent recording APIs need a Worker credential; the MCP binding uses the same charge. Your AI can operate Stripe's key-creation page and transfer its Copy result privately with `shop connect --from-clipboard`; you only handle Stripe's sign-in, verification and approval prompts. Live Base also needs your CDP facilitator credentials. The guide distinguishes this setup from the one-approval keyless default.
63
+ - Receipts, retries, private storage, refunds and payment recovery belong to your studio. **homie.rocks is not in the money path**, takes no fee, and can be unavailable while a buyer buys directly from your site.
64
+
65
+ ### Changed
66
+
67
+ - Part kinds are labels you choose; app, music and video are suggestions alongside existing game kinds. Existing manifests and game imports still work. App pieces use the same extraction, imports and credits as games; music and video retain their provenance and intended uses.
68
+ - Sellers choose licence scopes, refund windows, approval-intent lifetimes and recurring intervals supported by Stripe. No new price ceiling, seller kind list or spending cap. Turnstile on the hosted fallback is optional; request flood protection remains configurable.
69
+ - Paid resources use the shop's webhook verification and refund-list reader, including keyless signed refund snapshots. Each purchase keeps its frozen terms and private claim. A second purchase has a fresh claim; retries keep the first claim and never request a second charge.
70
+ - Selling code is loaded only by the selling entry. A studio that sells no parts keeps the ordinary Worker and its existing compatibility flags.
71
+
72
+ ### Upgrade notes
73
+
74
+ - Follows [#74](https://github.com/homie-rocks/homie/pull/74), studio 0.37.1 / plugin 0.38.1. Main’s apps, keyless shop and visible Shop control remain available.
75
+
76
+ - The AI applies `0015_purchases.sql` and `0016_purchase_facts.sql`, configures private storage, and uploads and verifies paid files when you deploy. Existing shop migrations stay unchanged. Keep paid files and the seller's signing key backed up.
77
+ - Payment Links need no Stripe API key in the Worker. A keyless refund is made in Stripe or by the owner's connected AI; signed events update the studio. Missing events wait for Stripe redelivery. Dynamic quantities and provider-read recovery use the fuller connection.
78
+ - Verification uses stand-in payment services in the local Workers runtime and Chrome. No real payment account was signed into and nothing was deployed. Actual account eligibility, settlement and provider permissions still need verification on the seller's account.
79
+
20
80
  ## [0.37.1] - 2026-10-09
21
81
 
22
82
  **Plugin 0.38.1** · [#74](https://github.com/homie-rocks/homie/pull/74)
@@ -336,7 +336,7 @@ import { publish, publishBefore } from '../lib/directory.mjs';
336
336
  import { look } from '../lib/look.mjs';
337
337
  import { importPort, planPort } from '../lib/port.mjs';
338
338
  import { portCheck } from '../lib/port-check.mjs';
339
- import { DEFAULT_GOAL, perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
339
+ import { perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
340
340
  import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
341
341
  import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
342
342
  import { newStudio } from '../lib/scaffold.mjs';
@@ -819,7 +819,8 @@ const OUTWARD_FLAGS = {
819
819
  'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
820
820
  'chat remove': ['url', 'all'], 'chat budget': ['url'],
821
821
  'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
822
- 'shop connect': ['url', 'managed', 'live', 'manual', 'renew'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send', 'cursor'],
822
+ 'parts reissue': ['claim-hash', 'receipt-verified', 'buyer'], 'parts retire': [], 'parts keys': [], 'parts refund': ['game'],
823
+ 'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
823
824
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
824
825
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
825
826
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
@@ -877,7 +878,7 @@ async function main() {
877
878
  }
878
879
  if (cmd === 'perf' && sub === 'compare') {
879
880
  if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
880
- return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal') ?? DEFAULT_GOAL), min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
881
+ return perfCompare(positional[2], positional[3], { goal: flags.has('goal') ? String(flags.get('goal')) : null, min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
881
882
  }
882
883
  // The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
883
884
  if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
@@ -1057,7 +1058,7 @@ async function main() {
1057
1058
  if (!sub) return shopStatus(root, { url });
1058
1059
  if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), currency: flags.get('currency') ?? 'usd', price: flags.get('price') ?? 500, managed: flags.has('managed') });
1059
1060
  if (sub === 'check') return shopCheck(root);
1060
- if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1061
+ if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1061
1062
  if (sub === 'disconnect') return shopDisconnect(root);
1062
1063
  if (sub === 'catalog') {
1063
1064
  const from = flags.get('have');
@@ -1072,7 +1073,7 @@ async function main() {
1072
1073
  return catalogPlan(root, { have, mode: mode ?? null });
1073
1074
  }
1074
1075
  if (sub === 'orders') return shopOrders(root, { url });
1075
- if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
1076
+ if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note'), manualTransaction: flags.get('manual-transaction') });
1076
1077
  if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), cursor: flags.get('cursor') ?? '', send: flags.has('send') });
1077
1078
  return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
1078
1079
  }
@@ -1326,7 +1327,7 @@ try {
1326
1327
  if (result && result.command !== 'help') print(result);
1327
1328
  if (result?.ok === false) process.exitCode = 1;
1328
1329
  // Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
1329
- if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1330
+ if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
1330
1331
  } catch (error) {
1331
1332
  await flushProgress().catch(() => {});
1332
1333
  print({ ok: false, why: error instanceof Error ? error.message : String(error) });
@@ -1,3 +1,4 @@
1
+ import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
1
2
  /**
2
3
  * `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
3
4
  * account, through Cloudflare's own tool (Wrangler, pinned in the studio).
@@ -349,12 +350,25 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
349
350
  step(`created D1 ${cf.d1}`);
350
351
  }
351
352
 
353
+ // Paid objects use a separate private bucket, so even old Worker versions cannot serve them.
354
+ if (paidReleases(root).length && !storage) return refuse('Paid releases need private storage. Run homie-studio storage add before deploying.');
355
+ const purchaseBucket = storage && `${storage.slice(0, 52)}-purchases`;
356
+ if (paidReleases(root).length && !created.has(`r2:${purchaseBucket}`)) {
357
+ const listed = w(['r2', 'bucket', 'list']);
358
+ if (listed.code !== 0) return refuse('Could not inspect private purchase storage', listed.out);
359
+ const buckets = parseJson(listed.out);
360
+ if (!Array.isArray(buckets)) return refuse('Could not read the private storage list', listed.out);
361
+ if (buckets.some((bucket) => bucket.name === purchaseBucket)) return refuse('The private purchase bucket already exists and is not owned by this studio');
362
+ const made = w(['r2', 'bucket', 'create', purchaseBucket]);
363
+ if (made.code !== 0) return refuse('Could not create private purchase storage', made.out);
364
+ remember(`r2:${purchaseBucket}`);
365
+ }
352
366
  // Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
353
367
  // free account with no payment method deploys the whole studio.
354
368
  const r2 = storage;
355
369
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
356
370
 
357
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
371
+ writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
358
372
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
359
373
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
360
374
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -362,7 +376,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
362
376
  step('D1 migrations applied');
363
377
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
364
378
  if (needsWorkersAi(root, w, cf.d1)) {
365
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
379
+ writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
366
380
  step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
367
381
  }
368
382
 
@@ -377,6 +391,11 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
377
391
  dep = w(['deploy', ...repoVar(root)]);
378
392
  }
379
393
  if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
394
+ if (paidReleases(root).length) {
395
+ const bin = wranglerBin(root);
396
+ const uploadOptions = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
397
+ await uploadPaidParts(root, { bucket: purchaseBucket, log, put: (key, file) => wranglerRun(bin, ['r2', 'object', 'put', key, '--file', file, '--remote'], uploadOptions), hash: (key) => hashRemote(bin, key, uploadOptions) });
398
+ }
380
399
  remember(`worker:${cf.worker}`);
381
400
  // The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
382
401
  // .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
@@ -394,6 +413,12 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
394
413
 
395
414
  // The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
396
415
  // reading it once now is all it takes; nothing is stored by hand.
416
+ if (paidReleases(root).length) {
417
+ const { withKey } = await import('./office.mjs');
418
+ const synced = await withKey(root, url, (call) => call('/_studio/api/shop/sync-offers', {}));
419
+ if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
420
+ step(`closed ${synced.expired} obsolete part Checkouts`);
421
+ }
397
422
  const directory = homie || studio.homie?.directory || 'https://homie.rocks';
398
423
  recordDeploy(root, builtNow);
399
424
  let claim = null;
@@ -655,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
655
680
  writeStudio(root, next);
656
681
  if (next.cloudflare.d1Id) {
657
682
  // The studio's own routes stay through this rewrite too (lib/routes.mjs).
658
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
683
+ writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
659
684
  }
660
685
  log(`created R2 ${bucket}`);
661
686
  return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
package/lib/dev.mjs CHANGED
@@ -81,7 +81,7 @@ export function runsFromStudio(pid, root) {
81
81
  export function processOfStudio(pid, root, kind) {
82
82
  if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !isAlive(pid)) return false;
83
83
  const cmd = commandOf(pid);
84
- if (cmd === null) return process.platform === 'win32'; // no ps: the registration file is all there is
84
+ if (cmd === null) return process.platform === 'win32' || runsFromStudio(pid, root); // no ps: the registration file is all there is
85
85
  if (kind === 'dev' ? /homie-studio/.test(cmd) : /wrangler|workerd/.test(cmd)) return true;
86
86
  return runsFromStudio(pid, root);
87
87
  }
@@ -1,4 +1,7 @@
1
1
  import { experienceDir, experienceFile } from './studio.mjs';
2
+ import { createHash } from 'node:crypto';
3
+ import { canonicalJson } from '../worker/referrals.mjs';
4
+ import { publicFiles, saleProblems } from "../worker/parts-sale.mjs";
2
5
  /**
3
6
  * GAME PARTS IN THE BUILD (parts/PARTS.md). Three small things lib/build.mjs and the deploy plan call, so the build
4
7
  * itself stays one line longer in each place:
@@ -14,7 +17,7 @@ import { experienceDir, experienceFile } from './studio.mjs';
14
17
  * partsPublishReport(root) before a game that uses parts goes online: each part's licence and attribution, and
15
18
  * plainly where licences cannot be combined.
16
19
  */
17
- import { cpSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
20
+ import { cpSync, readFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
18
21
  import { dirname, join } from 'node:path';
19
22
  import {
20
23
  assetRecords, licenceIssues, licenseOfPart, listOwnParts, packPart, packedDir, packedVersions, publicPart, readOrigins, readPart, safePath, shareProblems,
@@ -33,6 +36,7 @@ export function resolvePartImport(root, spec, lock = readOrigins(root)) {
33
36
  let dir = null; let rest = []; let ref = null; let vendored = false;
34
37
  if (segs.length >= 2 && lock.parts[`${segs[0]}/${segs[1]}`]) { ref = `${segs[0]}/${segs[1]}`; dir = vendorDir(root, ref); rest = segs.slice(2); vendored = true; }
35
38
  else if (segs.length >= 1 && existsSync(join(root, 'parts', segs[0], 'part.json'))) { ref = segs[0]; dir = join(root, 'parts', segs[0]); rest = segs.slice(1); }
39
+ if (vendored && lock.parts[ref]?.purchase?.scope === 'game' && lock.parts[ref].purchase.game == null) return { error: 'The paid part needs a named game licence' };
36
40
  if (!dir) return { error: `'${spec}' names a part this studio does not have. Parts here: ${[...Object.keys(lock.parts), ...listOwnParts(root).map((o) => o.id)].join(', ') || 'none'}. Bring one in with part_add ("<studio site>/<part id>").` };
37
41
  if (!existsSync(dir)) return { error: `'${spec}': parts/origins.json names ${ref} but its files are gone. Add it again (${ref}@${lock.parts[ref].version}).` };
38
42
  if (rest.length) {
@@ -50,7 +54,7 @@ export function resolvePartImport(root, spec, lock = readOrigins(root)) {
50
54
  /** The esbuild plugin (lib/build.mjs passes it to every game's bundle). `game`: the game being bundled, to credit. */
51
55
  export function partsPlugin(root, game = null) {
52
56
  return {
53
- name: 'homie-parts',
57
+ name: 'homie-purchases',
54
58
  setup(build) {
55
59
  const used = new Set();
56
60
  let lock = null;
@@ -58,6 +62,8 @@ export function partsPlugin(root, game = null) {
58
62
  build.onResolve({ filter: /^@parts\// }, (args) => {
59
63
  try { lock ??= readOrigins(root); } catch (error) { return { errors: [{ text: error.message }] }; }
60
64
  const r = resolvePartImport(root, args.path, lock);
65
+ const purchase = lock.parts[r.ref]?.purchases?.[game] ?? lock.parts[r.ref]?.purchase;
66
+ if (r.vendored && purchase?.scope === 'game' && game !== purchase.game) return { errors: [{ text: `The paid part ${r.ref} is licensed for ${purchase.game}, not ${game}. Use part_add to obtain this game's licence.` }] };
61
67
  if (r.error) return { errors: [{ text: r.error }] };
62
68
  if (r.vendored) used.add(r.ref);
63
69
  return { path: r.path };
@@ -103,19 +109,32 @@ export function buildParts(root, dist, { studio = {}, log = () => {} } = {}) {
103
109
  const sharing = sharedPartsOf(root);
104
110
  const names = new Map();
105
111
  const index = [];
112
+ const offerFile = join(root, 'parts', 'offers.json');
113
+ const offers = existsSync(offerFile) ? JSON.parse(readFileSync(offerFile, 'utf8')) : {};
106
114
  for (const { id, versions, part } of sharing) {
115
+ const releases = [];
107
116
  for (const v of versions) {
108
117
  const src = packedDir(root, id, v);
109
118
  const packed = readPart(src);
119
+ const config = offers[id];
120
+ // Release terms are authoritative. The optional price override survives releases.
121
+ const offer = packed.sale ? { ...packed.sale, ...(config?.price ?? {}) } : null;
122
+ if (offer && saleProblems({ ...packed, sale: offer }).length) throw new Error(`Invalid offer for ${id}`);
123
+ const selected = config?.releases?.[v];
124
+ const onSale = Boolean(offer && config?.active !== false && selected?.active !== false && (v === versions.at(-1) || selected?.keepOnSale === true));
125
+ if (offer && packed.sale.amount !== offer.amount) log(`warning: ${id} ${v} manifest price is historical; the current offer is ${offer.amount}. Use parts offer to change the selling price.`);
126
+ if (selected?.upgrade !== undefined && !['included', 'paid'].includes(selected.upgrade)) throw new Error('Release upgrade must be included or paid');
127
+ releases.push({ onSale, offerVersion: offer ? createHash('sha256').update(canonicalJson({ resource: { kind: 'part', id, release: v, manifest: createHash('sha256').update(canonicalJson(packed)).digest('hex') }, terms: offer })).digest('hex') : null, offer, upgrade: selected?.upgrade ?? 'paid', version: v, license: packed.license, licenseTerms: packed.licenseTerms ?? null, sale: packed.sale ?? null });
110
128
  if (!verifyFiles(src, packed).ok) throw new Error(`parts/_packed/${id}/${v} no longer matches its hashes: a shared version never changes. Restore it from git.`);
111
129
  const dest = join(out, id, v);
112
- for (const f of packed.files) { mkdirSync(dirname(join(dest, f.path)), { recursive: true }); cpSync(join(src, f.path), join(dest, f.path)); }
130
+ for (const f of packed.files.filter((f) => !packed.sale || publicFiles(packed).has(f.path))) { mkdirSync(dirname(join(dest, f.path)), { recursive: true }); cpSync(join(src, f.path), join(dest, f.path)); }
131
+ mkdirSync(dest, { recursive: true });
113
132
  writeFileSync(join(dest, 'part.json'), `${JSON.stringify(publicPart({ ...packed, share: true }))}\n`);
114
133
  }
115
134
  // The game it came out of, by the name the studio gives it.
116
135
  const g = part.from?.game ?? part.from?.app;
117
136
  if (g && !names.has(g)) names.set(g, gameFacts(root, g).name);
118
- index.push({ ...part, ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions });
137
+ index.push({ ...part, ...(part.sale ? { sale: releases.at(-1).offer } : {}), ...(g ? { from: { ...part.from, name: names.get(g) } } : {}), url: `/parts/${id}/${versions.at(-1)}/`, page: `/parts/${id}/`, versions, releases });
119
138
  log(`shared part ${id} (${versions.join(', ')})`);
120
139
  }
121
140
  mkdirSync(out, { recursive: true });
package/lib/parts-cli.mjs CHANGED
@@ -1,3 +1,8 @@
1
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { readPart, partDir } from './parts.mjs';
4
+ import { saleProblems } from "../worker/parts-sale.mjs";
5
+ import { purchasePortal, prepareRecovery } from './parts-purchase.mjs';
1
6
  /**
2
7
  * `homie-studio parts …`: the plumbing behind the four chat tools (lib/parts-tools.mjs; parts/PARTS.md). A creator
3
8
  * works in chat and never types these; an agent with a shell and no MCP connection may.
@@ -14,7 +19,7 @@
14
19
  import { newPart } from './parts.mjs';
15
20
  import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
16
21
 
17
- export const PARTS_VERBS = ['find', 'new', 'add', 'share', 'unshare', 'check'];
22
+ export const PARTS_VERBS = ['find', 'new', 'add', 'share', 'unshare', 'check', 'manage', 'recover', 'reissue', 'keys', 'offer', 'refund', 'retire', 'test-access'];
18
23
 
19
24
  /**
20
25
  * What `parts add` takes, in the words the command prints when it is not given a part. `--no-install` is said as
@@ -32,7 +37,51 @@ export async function partsCommand(root, sub, positional, flags, { log = () => {
32
37
  case 'check': return checkParts(root, arg ?? null, { write: true });
33
38
  case 'share': return arg ? sharePart(root, arg, true) : { ok: false, command: 'parts share', why: 'name the part to share' };
34
39
  case 'unshare': return arg ? sharePart(root, arg, false) : { ok: false, command: 'parts unshare', why: 'name the part to stop sharing' };
35
- case 'add': return arg ? addPart(root, arg, { fetch, ...(npm ? { npm } : {}), install: !flags.has('no-install'), say: log, game: str('game'), overwrite: flags.has('overwrite') }) : { ok: false, command: 'parts add', why: `name the part: "<studio site>/<part id>"\n${PARTS_ADD_USAGE}` };
40
+ case 'offer': {
41
+ const p = readPart(partDir(root, arg)); const version = str('release');
42
+ if (str('upgrade') && (!version || !['included', 'paid'].includes(str('upgrade')))) throw new Error('--upgrade included|paid requires --release');
43
+ if (flags.has('keep-on-sale') && !version) throw new Error('--keep-on-sale requires --release');
44
+ const file = join(root, 'parts', 'offers.json'); const offers = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
45
+ const old = offers[arg] ?? {};
46
+ const price = { ...old.price, ...(str('amount') ? { amount: Number(str('amount')) } : {}) };
47
+ const problems = saleProblems({ ...p, sale: { ...p.sale, ...price } });
48
+ if (problems.length) throw new Error(problems.join('; '));
49
+ offers[arg] = { price, active: !version && flags.has('withdraw') ? false : flags.has('on-sale') ? true : old.active !== false, releases: { ...old.releases } };
50
+ if (version) offers[arg].releases[version] = { ...old.releases?.[version], ...(flags.has('withdraw') ? { active: false } : flags.has('on-sale') ? { active: true } : {}), ...(flags.has('keep-on-sale') ? { keepOnSale: true } : {}), ...(str('upgrade') ? { upgrade: str('upgrade') } : {}) };
51
+ writeFileSync(file, JSON.stringify(offers, null, 2) + '\n');
52
+ return { ok: true, command: 'parts offer', message: 'Offer updated. Deploy to publish it. Existing purchase terms stay unchanged.' };
53
+ }
54
+ case 'test-access': {
55
+ const { withKey } = await import('./office.mjs');
56
+ return withKey(root, null, (call) => call('/_studio/api/shop/test-access', { order: arg }));
57
+ }
58
+ case 'recover': return prepareRecovery(root, arg, str('order'), str('game'));
59
+ case 'reissue': {
60
+ const { withKey } = await import('./office.mjs');
61
+ return withKey(root, null, (call) => call('/_studio/api/shop/reissue', { order: arg, claimHash: str('claim-hash'), receiptVerified: flags.has('receipt-verified'), ...(str('buyer') ? { buyer: str('buyer') } : {}) }));
62
+ }
63
+ case 'keys': {
64
+ const { generateKeyPair, exportJWK } = await import('jose');
65
+ const { runner } = await import('./cloudflare.mjs');
66
+ const { siteUrl } = await import('./studio.mjs');
67
+ const url = siteUrl(root);
68
+ let old = [];
69
+ try { const r = await (fetch ?? globalThis.fetch)(`${url}/purchases/keys.json`); if (r.ok) old = (await r.json()).keys; else if (r.status !== 404 || !flags.has('initialize')) throw new Error('Cannot read keys; only an explicit --initialize accepts a missing endpoint'); } catch { throw new Error('Cannot read the current public key ring; restore connectivity before rotating'); }
70
+ if (!Array.isArray(old) || old.length >= 32) throw new Error('Retain the existing ring. Its 32-key protocol limit requires an explicit key retirement before rotating again');
71
+ const pair = await generateKeyPair('Ed25519', { extractable: true });
72
+ const ring = [await exportJWK(pair.privateKey), ...old.map(({ kty, crv, x }) => ({ kty, crv, x }))];
73
+ const r = runner(root)(['secret', 'put', 'PURCHASE_SIGNING_KEYS'], { input: JSON.stringify(ring) + '\n' });
74
+ return { ok: r.code === 0, command: 'parts keys', message: 'The parts signing key is a Worker secret. Retained public keys verify earlier purchases.' };
75
+ }
76
+ case 'refund': return purchasePortal(root, arg, { game: str('game'), fetch, action: 'refund' });
77
+ case 'retire': {
78
+ const { withKey } = await import('./office.mjs');
79
+ const result = await withKey(root, null, (call) => call('/_studio/api/shop/retire', { kind: 'part', resource: arg }));
80
+ if (result.ok) sharePart(root, arg, false);
81
+ return { ...result, command: 'parts retire' };
82
+ }
83
+ case 'manage': return purchasePortal(root, arg, { game: str('game'), fetch });
84
+ case 'add': return arg ? addPart(root, arg, { fetch, ...(npm ? { npm } : {}), install: !flags.has('no-install'), approve: str('approve'), wallet: str('wallet'), quantity: str('quantity') ? Number(str('quantity')) : 1, offline: flags.has('offline'), say: log, game: str('game'), overwrite: flags.has('overwrite') }) : { ok: false, command: 'parts add', why: `name the part: "<studio site>/<part id>"\n${PARTS_ADD_USAGE}` };
36
85
  default: return { ok: false, command: 'parts', why: `"${sub}" is not a parts command: ${PARTS_VERBS.join(', ')}` };
37
86
  }
38
87
  }
@@ -46,6 +95,8 @@ const packageLines = (rows) => rows.filter((p) => p.state !== 'ok').map((p) => (
46
95
 
47
96
  function addLines(r) {
48
97
  const L = [];
98
+ if (r.purchase?.offline) L.push('Restored from the verified private backup. Refund status is only last known; the seller was not contacted.');
99
+ if (r.purchase?.mode === 'test') L.push('TEST purchase: this is not proof of a live payment.');
49
100
  if (r.was && r.same) L.push(`${r.ref} ${r.version} is already here and unchanged.`);
50
101
  else if (r.was) L.push(`${r.ref}: ${r.was} → ${r.version}. ${[r.changes.changed.length ? `changed: ${r.changes.changed.join(', ')}` : '', r.changes.added.length ? `new: ${r.changes.added.join(', ')}` : '', r.changes.removed.length ? `gone: ${r.changes.removed.join(', ')}` : ''].filter(Boolean).join('; ') || 'the same files'}. Your tuning.json was kept${r.replaced?.length ? `; REPLACED your edited ${r.replaced.join(', ')} as asked` : ''}.`);
51
102
  else L.push(`${r.name} ${r.version} (${r.kind}) from ${r.host} is in ${r.dir}: ${r.files} files, every one checked against its SHA-256. It is this studio's to tune now.`);
@@ -67,7 +118,7 @@ export function partsLines(r) {
67
118
  if (!r.hub.ok) L.push(r.hub.why, '');
68
119
  if (!r.results.length) L.push(r.hub.ok ? `No shared part matches${r.query ? ` "${r.query}"` : ''} (${r.searched.hub} in the catalogue, ${r.searched.here} in this studio). Write it; if it turns out reusable, it can become a part.` : 'Nothing in this studio matches.');
69
120
  for (const p of r.results) {
70
- L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
121
+ L.push(`${p.name} (${p.kind}) ${p.version ?? ''} · ${p.license ?? 'no licence named'} · ${p.price ?? 'Free'} · ${p.where === 'hub' ? p.ref : p.where === 'here' ? `${p.ref} (already here)` : `${p.id} (this studio's own${p.share ? ', shared' : ', private'})`}`);
71
122
  if (p.summary) L.push(` ${p.summary}`);
72
123
  if (p.from?.name || (p.from?.game ?? p.from?.app)) L.push(` from ${p.from.name ?? (p.from.game ?? p.from.app)}${p.from.studio ? ` by ${p.from.studio}` : ''}`);
73
124
  const bits = [costWords(p.cost) && `costs ${costWords(p.cost)}`, buildsWords(p.requires) && `builds on ${buildsWords(p.requires)}`, p.rig && `rig ${p.rig}`, p.netplay && `in a room: ${p.netplay}`].filter(Boolean);
@@ -0,0 +1,174 @@
1
+ import { payWithWallet } from './purchase-wallet.mjs';
2
+ /** Buyer purchase records and pristine backups never belong in public source or game bundles. */
3
+ import { randomBytes } from 'node:crypto';
4
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { digest, manifestHash, verifyGrant } from "../worker/purchase-crypto.mjs";
7
+ import { priceWords } from "../worker/purchase-pricing.mjs";
8
+ import { quoteHash } from "../worker/parts-sale.mjs";
9
+ import { checkPart, parseRef, sha256 } from './parts.mjs';
10
+
11
+ const directory = (root) => join(root, '.homie', 'paid-parts');
12
+ const recordName = (ref, game) => sha256(`${ref}\n${game ?? ''}`);
13
+ function privateWrite(root, path, value) {
14
+ mkdirSync(directory(root), { recursive: true, mode: 0o700 });
15
+ const ignore = join(root, '.gitignore');
16
+ const text = existsSync(ignore) ? readFileSync(ignore, 'utf8') : '';
17
+ if (!text.split('\n').includes('/.homie/paid-parts/')) writeFileSync(ignore, `${text}${text && !text.endsWith('\n') ? '\n' : ''}/.homie/paid-parts/\n`);
18
+ writeFileSync(`${path}.tmp`, JSON.stringify(value), { mode: 0o600 }); chmodSync(`${path}.tmp`, 0o600); renameSync(`${path}.tmp`, path);
19
+ }
20
+ function read(path) { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return null; } }
21
+ const recordPath = (root, ref, game) => join(directory(root), `${recordName(ref, game)}.json`);
22
+ export async function purchaseFetch(url, { fetch: f = globalThis.fetch, method = 'GET', body, authorization, max = 1024 * 1024 } = {}) {
23
+ const r = await f(url, { method, redirect: 'error', signal: AbortSignal.timeout(30000), headers: { accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}), ...(authorization ? { authorization: `Bearer ${authorization}` } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) });
24
+ const reader = r.body?.getReader(); let count = 0; const chunks = [];
25
+ if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; count += value.length; if (count > max) { await reader.cancel(); throw new Error('Purchase response exceeds its size limit'); } chunks.push(value); }
26
+ let data;
27
+ try { data = JSON.parse(Buffer.concat(chunks).toString()); } catch { throw new Error(`Purchase service answered ${r.status} without JSON`); }
28
+ return { status: r.status, data };
29
+ }
30
+ function purchaseNeeded(purchase) {
31
+ const error = new Error(purchase.message); error.purchase = purchase; throw error;
32
+ }
33
+
34
+ /** Request payment from the existing wallet only for the approved immutable quote. */
35
+ export async function paidAccess(root, ref, part, { game = null, quantity = 1, approve = null, fetch, offer = part.sale, wallet, walletPay = payWithWallet } = {}) {
36
+ game = offer.scope === 'game' ? game : null;
37
+ const origin = `https://${ref.host}`; const hash = await manifestHash(part); const quote = await quoteHash(part, quantity, game, offer); const path = recordPath(root, ref.ref, game);
38
+ let record = read(path);
39
+ let exchange;
40
+ if (record?.recovery) {
41
+ const recovered = await purchaseFetch(`${origin}/api/purchases/recover`, { fetch, method: 'POST', authorization: record.claim, body: {} });
42
+ if (!recovered.data.ok || recovered.data.order !== record.order) throw new Error('Seller has not reissued this purchase yet');
43
+ const r = recovered.data;
44
+ record = { ...record, recovery: false, buyer: r.buyer, quantity: r.quantity, game: r.game, version: r.manifest.version, manifestHash: await manifestHash(r.manifest), quote: await quoteHash(r.manifest, r.quantity, r.game), price: priceWords(r.manifest.sale, r.quantity) };
45
+ privateWrite(root, recordPath(root, ref.ref, r.game), record);
46
+ }
47
+ if (!record) {
48
+ if (approve !== quote) purchaseNeeded({ required: true, quote, part: ref.ref, version: part.version, price: priceWords(offer, quantity), sale: offer, licence: part.license, terms: `${origin}/parts/${ref.id}/${part.version}/${part.licenseTerms}`, quantity, game, message: `Ask first: ${part.name} from ${ref.host}, ${priceWords(offer, quantity)}. Show the licence, refund and recurring terms. Only authority for this exact price permits requesting payment through the person’s wallet; Checkout is the fallback.` });
49
+ if (!Number.isSafeInteger(quantity) || quantity < 1) throw new Error('Quantity must be a positive whole number');
50
+ if (offer.scope === 'game' && !game) throw new Error('This licence needs the game being licensed');
51
+ const identityPath = join(directory(root), 'identity.json'); let identity = read(identityPath);
52
+ if (!identity) { identity = { buyer: randomBytes(32).toString('hex') }; privateWrite(root, identityPath, identity); }
53
+ record = { createdAt: Date.now(), buyer: identity.buyer, buyerName: String(read(join(root, 'studio.json'))?.name ?? 'Buying studio').slice(0, 80), claim: randomBytes(32).toString('hex'), quote, manifestHash: hash, version: part.version, price: priceWords(offer, quantity), quantity, game, origin, ref: ref.ref };
54
+ privateWrite(root, path, record);
55
+ }
56
+ if (!record.order) {
57
+ // A retry of a previously approved intent is bound to its original quote and quantity.
58
+ if ((record.quote !== quote || record.quantity !== quantity) && approve === quote) { privateWrite(root, path, null); return paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay }); }
59
+ if (record.quote !== quote || record.quantity !== quantity) purchaseNeeded({ required: true, quote, price: priceWords(offer, quantity), message: 'Price, terms or quantity changed. Approve the new quote with a new purchase record.' });
60
+ if (offer.billing === 'one-time' && offer.currency === 'usd' && !offer.automaticTax && offer.taxBehavior !== 'exclusive' && (wallet || process.env.HOMIE_PARTS_WALLET)) {
61
+ const paid = await walletPay(`${origin}/api/purchases/resource`, { kind: 'part', resource: part.id, version: part.version, buyer: record.buyer, claim: record.claim, quote, quantity, game, amount: offer.amount * quantity }, { client: wallet, pending: record.walletPending });
62
+ if (paid?.pending) {
63
+ record.walletPending = paid.pending; privateWrite(root, path, record);
64
+ purchaseNeeded({ required: true, wallet: paid.approval, quote, price: priceWords(offer, quantity), message: 'Approve this spend in your wallet app, then call part_add again. The wallet controls authority and limits.' });
65
+ }
66
+ record.walletPending = null;
67
+ privateWrite(root, path, record);
68
+ if (paid?.data?.order) {
69
+ exchange = paid.data;
70
+ record.order = paid.data.order; privateWrite(root, path, record);
71
+ } else {
72
+ const reason = paid?.data?.detail ?? paid?.data?.message ?? 'The wallet could not complete this payment';
73
+ if (paid?.uncertain || /Settlement|recording|processing|do not pay again/i.test(reason)) throw new Error(reason);
74
+ record.walletUnavailable = reason;
75
+ // Retain this private claim so a corrected wallet can retry it.
76
+ privateWrite(root, path, record);
77
+ if (!paid?.data?.checkout) purchaseNeeded({ required: true, quote, price: priceWords(offer, quantity), walletUnavailable: reason, machine: `${origin}/api/purchases/resource`, message: `${reason}. Retry part_add with the corrected wallet, or omit wallet to choose the hosted Checkout fallback. No payment was confirmed.` });
78
+ }
79
+ }
80
+ if (!record.order) {
81
+ if (record.walletUnavailable) { record.claim = randomBytes(32).toString('hex'); privateWrite(root, path, record); }
82
+ const { status, data } = await purchaseFetch(`${origin}/api/purchases/intent`, { fetch, method: 'POST', body: { kind: 'part', resource: part.id, version: part.version, buyer: record.buyer, buyerName: record.buyerName, claimHash: await digest(record.claim), quote, quantity: record.quantity, game } });
83
+ if (status !== 200 || !data.ok) throw new Error(data.message ?? 'Purchase could not be prepared');
84
+ if (new URL(data.url).origin !== origin) throw new Error('Purchase URL is not on the seller origin');
85
+ record.order = data.order; record.url = data.url; privateWrite(root, path, record);
86
+ purchaseNeeded({ required: true, checkout: record.url, price: priceWords(offer, quantity), message: `${record.walletUnavailable ? record.walletUnavailable + ". " : ""}Machine payments are also available: name link-cli or purl as the wallet before starting Checkout. Hosted Checkout fallback: the person opens ${record.url}, reads the licence and pays on Stripe. Then call part_add again. Nothing has been charged by the assistant.` });
87
+ }
88
+ }
89
+ const { status, data } = exchange ? { status: 200, data: exchange } : await purchaseFetch(`${origin}/api/purchases/grant`, { fetch, method: 'POST', authorization: record.claim, body: { version: part.version } });
90
+ if (status !== 200 || !data.ok) {
91
+ if (status === 401 && record.createdAt && Date.now() - record.createdAt > (offer.intentMinutes ?? 30) * 60000) data.status = 'expired';
92
+ if (approve === quote && ((status === 401 && quote !== record.quote) || (status === 403 && quote !== record.quote) || ['expired', 'failed', 'refunded', 'lost'].includes(data.status))) {
93
+ privateWrite(root, join(directory(root), `${recordName(ref.ref, game)}-${record.order}.purchase.json`), record);
94
+ privateWrite(root, path, null);
95
+ return paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay });
96
+ }
97
+ if (data.status) record.status = data.status;
98
+ if (data.paidUntil !== undefined) record.paidUntil = data.paidUntil;
99
+ record.checkedAt = new Date().toISOString(); privateWrite(root, path, record);
100
+ purchaseNeeded({ required: true, checkout: record.url, status: record.status, quote, price: priceWords(offer, quantity), sale: offer, licence: part.license, terms: `${origin}/parts/${ref.id}/${part.version}/${part.licenseTerms}`, message: data.message ?? 'Finish the purchase on Stripe, then call part_add again' });
101
+ }
102
+ const { calculateJwkThumbprint, decodeProtectedHeader } = await import('jose');
103
+ let jwk = record.jwk;
104
+ if (!jwk || decodeProtectedHeader(data.token).kid !== await calculateJwkThumbprint(jwk)) {
105
+ const keys = await purchaseFetch(`${origin}/purchases/keys.json`, { fetch });
106
+ if (keys.status !== 200 || !Array.isArray(keys.data.keys) || !keys.data.keys.length) throw new Error('The seller supplied no unambiguous signing key');
107
+ jwk = keys.data.keys.find((k) => k.kid === decodeProtectedHeader(data.token).kid);
108
+ if (!jwk) throw new Error('Seller key is not in its HTTPS key ring');
109
+ }
110
+ const claims = await verifyGrant(data.token, jwk, { issuer: origin, subject: record.buyer, kind: 'part', resource: part.id, version: part.version });
111
+ if (claims.manifest !== hash || await manifestHash(data.manifest) !== hash || (part.version === record.version && hash !== record.manifestHash) || claims.jti !== record.order || (claims.quantity !== record.quantity && claims.billing === 'one-time') || claims.game !== record.game || (record.mode && record.mode !== claims.mode)) throw new Error('The signed licence differs from the approved purchase');
112
+ const evidence = await verifyGrant(data.proof, jwk, { proof: true, issuer: origin, subject: record.buyer, kind: 'part', resource: part.id, version: part.version });
113
+ if (evidence.manifest !== claims.manifest || evidence.jti !== claims.jti) throw new Error('Purchase evidence does not match the download');
114
+ record = { ...record, trustedKeys: [...new Map([...(record.trustedKeys ?? []), ...(record.jwk ? [record.jwk] : []), jwk].map((k) => [k.x, k])).values()], quantity: claims.quantity, paidUntil: claims.paidUntil, jwk, token: data.token, proof: data.proof, mode: claims.mode, status: 'paid', checkedAt: new Date().toISOString() }; privateWrite(root, path, record);
115
+ return { token: data.token, proof: data.proof, claims, jwk, record, files: exchange?.files };
116
+ }
117
+
118
+ export function savePurchaseBackup(root, got, access, game) {
119
+ game = access.claims.game;
120
+ const path = recordPath(root, got.ref.ref, game); const record = read(path);
121
+ // One JSON archive retains exact bytes, including files the buyer later edits. Its token is private.
122
+ const archive = { ref: got.ref, part: got.part, entry: got.entry, base: got.base, version: got.version, versions: got.versions, token: access.proof, jwk: access.jwk, buyer: record.buyer, files: [...got.files].map(([path, bytes]) => [path, bytes.toString('base64')]) };
123
+ const name = `${recordName(got.ref.ref, game)}-${got.version}.backup.json`;
124
+ privateWrite(root, join(directory(root), name), archive);
125
+ record.backup = name; privateWrite(root, path, record);
126
+ }
127
+ export async function offlinePurchase(root, refText, game) {
128
+ const ref = parseRef(refText); if (!ref) throw new Error('Name a part');
129
+ const record = read(recordPath(root, ref.ref, game)) ?? read(recordPath(root, ref.ref, null));
130
+ game = record?.game;
131
+ const name = ref.version ? `${recordName(ref.ref, game)}-${ref.version}.backup.json` : record?.backup;
132
+ const backup = name ? read(join(directory(root), name)) : null;
133
+ if (!backup) throw new Error('No private purchase backup for this part and game');
134
+ if (backup.ref?.ref !== ref.ref || backup.ref?.host !== ref.host || backup.ref?.id !== ref.id || backup.version !== backup.part?.version || backup.base !== record.origin) throw new Error('Purchase backup identifies another release');
135
+ if (![...(record.trustedKeys ?? []), record.jwk].some((key) => key?.x === backup.jwk?.x)) throw new Error('Archive key is not a pinned seller key');
136
+ const claims = await verifyGrant(backup.token, backup.jwk, { proof: true, issuer: backup.base, subject: backup.buyer, kind: 'part', resource: ref.id, version: backup.version });
137
+ if (!checkPart(backup.part).ok || claims.manifest !== await manifestHash(backup.part)) throw new Error('Purchase backup manifest failed verification');
138
+ if (record.status === 'refunded' || record.status === 'lost') { if (claims.onRefund === 'terminate') throw new Error('This purchase was revoked under its refund terms'); }
139
+ if (claims.paidUntil && claims.paidUntil <= Date.now() && claims.onExpiry === 'terminate') throw new Error('This subscription licence has expired');
140
+ const files = new Map(backup.files.map(([path, bytes]) => [path, Buffer.from(bytes, 'base64')]));
141
+ if (files.size !== backup.part.files.length || files.size !== backup.files.length) throw new Error('Purchase backup contains unlisted files');
142
+ for (const f of backup.part.files) if (!files.has(f.path) || files.get(f.path).length !== f.bytes || sha256(files.get(f.path)) !== f.sha256) throw new Error('Purchase backup files failed verification');
143
+ return { ...backup, files, purchase: { version: claims.version, mode: claims.mode, scope: claims.terms.scope, game: claims.game, quantity: claims.quantity, paidUntil: claims.paidUntil, onExpiry: claims.onExpiry, onRefund: claims.onRefund, checkedAt: record.checkedAt, offline: true, status: record.status } };
144
+ }
145
+ export async function purchasePortal(root, refText, { game = null, fetch, action = 'portal' } = {}) {
146
+ const ref = parseRef(refText); const record = ref && (read(recordPath(root, ref.ref, game)) ?? read(recordPath(root, ref.ref, null)));
147
+ if (!record?.order && action === 'portal' && ref) {
148
+ const { status, data } = await purchaseFetch(`https://${ref.host}/api/purchases/portal-login`, { fetch });
149
+ if (status !== 200) throw new Error(data.message);
150
+ return { ...data, command: 'parts manage' };
151
+ }
152
+ if (!record?.order) throw new Error('No purchase here to manage');
153
+ const { status, data } = await purchaseFetch(`${record.origin}/api/purchases/${action}`, { fetch, method: 'POST', authorization: record.claim, body: {} });
154
+ if (status !== 200) throw new Error(data.message ?? 'Stripe portal unavailable');
155
+ if (action === 'refund') return { ...data, command: 'parts refund' };
156
+ return { ok: true, command: 'parts manage', url: data.url, message: `Open ${data.url} to manage or cancel this subscription in Stripe.` };
157
+ }
158
+
159
+ /** Last checked state for publish reports; private credentials never leave this module. */
160
+ export function purchaseFacts(root, ref, game) {
161
+ const r = read(recordPath(root, ref, game)) ?? read(recordPath(root, ref, null));
162
+ return r ? { status: r.status, paidUntil: r.paidUntil, checkedAt: r.checkedAt } : {};
163
+ }
164
+
165
+ /** Prepare a recovery request without giving the seller the new claim secret. */
166
+ export function prepareRecovery(root, refText, order, game = null) {
167
+ const ref = parseRef(refText);
168
+ if (!ref || !/^ord_[A-Za-z0-9]{20}$/.test(order ?? '')) throw new Error('Supply the part and order from the receipt or seller');
169
+ const existing = read(recordPath(root, ref.ref, game)) ?? read(recordPath(root, ref.ref, null));
170
+ if (existing?.claim && !existing.recovery) return { ok: false, command: 'parts recover', message: 'A healthy purchase claim already exists; keep it and run parts add' };
171
+ const claim = randomBytes(32).toString('hex');
172
+ privateWrite(root, recordPath(root, ref.ref, game), { claim, order, origin: `https://${ref.host}`, ref: ref.ref, game, recovery: true });
173
+ return digest(claim).then((claimHash) => ({ ok: true, command: 'parts recover', order, claimHash, message: 'Give the seller this order and claimHash with your Stripe receipt. After reissue, run parts add again. Keep this private purchase folder.' }));
174
+ }