@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
package/lib/mcp.mjs CHANGED
@@ -33,22 +33,17 @@ export const MCP_APP_MIME = 'text/html;profile=mcp-app';
33
33
 
34
34
  export const INSTRUCTIONS = `Homie Studio, on this computer: make game studios and their multiplayer web games, music and videos, run them here, and put them online on the studio's own Cloudflare. (It is not the Homie house app: that app's own server, with its room and TV tools, is a different one.) These tools do the work in this chat; the person never types a command and never needs a terminal. A studio is a folder in the studios folder; the file tools (file_list, file_read, file_edit, file_write, file_search) work inside it.
35
35
 
36
- A new studio follows one checklist, in order and never ahead. Show it in your first reply and again, ticked, as each step ends:
37
- 0. Setup status (setup_status; for a studio that is not made yet, pass its name as studio, so the checklist is the new studio's and never another's): what this computer and their accounts have; optional rows never block.
38
- 1. The studio (studio_scaffold): it has NO game; its home page says "First game coming soon".
39
- 2. See a working game (game_demo): a live game on Homie Arcade with its Play link, nothing copied in. Copy a starter in (game_make) only if they ask.
40
- 3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
41
- 4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
42
- 5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
43
- For a shop: stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through Stripe CLI browser approval. Follow studio_job. Default is keyless: no API key in the Worker, no human-handled key. Rerun after shop.json changes. Never expose CLI credentials or webhook secrets. Follow the single next step, verify purchase/webhook/grant and a refund in Stripe or through the connected AI. An existing Worker key selects fuller carts, caps, expiry, automatic recovery and office refunds; explain that only when requested. Never choose manual automatically. Lost webhook: resend from Stripe, never grant from the return URL. Live only when requested.
44
- 6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
45
- If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
36
+ Carry the person's request through to a working result without a lesson or an interview. Use their name or business name, otherwise "My Studio". Setup, dependencies, a plan, building and checking are AI work, not approval gates. Choose engineering and design defaults from the request; record them in CODEX.md so the person can change them later by asking. Phone and computer controls, a small first version, local sound and CC0 art are the defaults. No paid media unless requested with a budget. A business or charity may need an app or a site, not a game: do not make them play a demo or choose a genre.
37
+ Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
38
+ Show progress briefly and give the preview link as soon as it works. For going live, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
39
+ For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
40
+ For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
46
41
 
47
42
  If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
48
43
 
49
44
  A studio made elsewhere (on a phone, with Deploy to Cloudflare, so it lives on GitHub): studio_open with its repo clones it into the studios folder with this computer's own GitHub sign-in; github_login signs the computer in with GitHub's one-time code. Never ask for a token.
50
45
 
51
- Links are the person's to open: give them in your reply (the cards have their own buttons). Never open a browser or run a command such as open to open one yourself.
46
+ Give preview and finished-site links in your reply (the cards have their own buttons). Open provider authorization flows when needed; the person signs in and approves, while you handle configuration.
52
47
 
53
48
  To make one mechanic of a game feel better (a jump, a hit, a dash), game_lab opens the Game Lab: New beside Today, one take, its phases and sliders (studio_guide { "topic": "lab" }).
54
49
 
@@ -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
+ }
@@ -1,3 +1,6 @@
1
+ import { paidAccess, savePurchaseBackup, offlinePurchase, purchaseFacts } from './parts-purchase.mjs';
2
+ import { priceWords } from "../worker/purchase-pricing.mjs";
3
+
1
4
  /**
2
5
  * FINDING, ADDING AND SHARING PARTS (parts/PARTS.md): what the four chat tools do (lib/parts-tools.mjs).
3
6
  *
@@ -37,13 +40,13 @@ export function baseOf(host) {
37
40
  }
38
41
 
39
42
  /** Every request this file makes. Bytes back, capped; a plain sentence when it fails. */
40
- async function get(url, { fetch: f = globalThis.fetch, max = LIMITS.fileBytes, what = 'the file' } = {}) {
43
+ async function get(url, { fetch: f = globalThis.fetch, max = LIMITS.fileBytes, what = 'the file', authorization } = {}) {
41
44
  let res;
42
- try { res = await f(url, { redirect: 'follow', headers: { accept: '*/*' } }); } catch (error) { throw new Error(`${new URL(url).host} could not be reached (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)})`); }
45
+ try { res = await f(url, { redirect: authorization ? 'error' : 'follow', headers: { accept: '*/*', ...(authorization ? { authorization: `Bearer ${authorization}` } : {}) }, signal: AbortSignal.timeout(30000) }); } catch (error) { throw new Error(`${new URL(url).host} could not be reached (${String(error?.cause?.code ?? error?.message ?? error).slice(0, 80)})`); }
43
46
  if (!res.ok) throw new Error(`${url} answered ${res.status}: ${what} is not there`);
44
- const bytes = Buffer.from(await res.arrayBuffer());
45
- if (bytes.length > max) throw new Error(`${url} is ${bytes.length} bytes, more than ${what} may be (${max})`);
46
- return bytes;
47
+ const reader = res.body?.getReader(); const chunks = []; let size = 0;
48
+ if (reader) for (;;) { const { done, value } = await reader.read(); if (done) break; size += value.length; if (size > max) { await reader.cancel(); throw new Error(`${url} exceeds the size ${what} may be (${max} bytes)`); } chunks.push(value); }
49
+ return Buffer.concat(chunks);
47
50
  }
48
51
  async function getJson(url, opts) {
49
52
  const bytes = await get(url, { max: 4 * 1024 * 1024, ...opts });
@@ -55,12 +58,22 @@ async function getJson(url, opts) {
55
58
  * Nothing is returned unless every file's SHA-256 and size are what the part.json says, so nothing unverified can
56
59
  * be written by a caller.
57
60
  */
58
- export async function fetchPart(refText, { fetch } = {}) {
61
+ export async function fetchPart(refText, { fetch, onPaid } = {}) {
59
62
  const ref = parseRef(refText);
60
63
  if (!ref) throw new Error(`"${String(refText).slice(0, 80)}" does not name a part: "<studio site>/<part id>", such as "owls.example/pickup-field" (and "@1.2.0" for one version)`);
61
64
  const base = baseOf(ref.host);
62
- const index = await getJson(`${base}/.well-known/homie-parts.json`, { fetch, what: 'the studio\'s list of shared parts' }).catch((error) => { throw new Error(`${ref.host} shares no parts that can be read: ${error.message}`); });
63
- const entry = (Array.isArray(index?.parts) ? index.parts : []).find((p) => p?.id === ref.id);
65
+ let catalog = `${base}/parts/catalog.json`;
66
+ try {
67
+ const discovery = await getJson(`${base}/.well-known/api-catalog`, { fetch, what: 'the studio API catalogue' });
68
+ const candidate = discovery.linkset?.flatMap((item) => item.item ?? []).find((item) => item.type === 'application/json' && new URL(item.href).origin === base);
69
+ if (candidate) catalog = candidate.href;
70
+ } catch { /* Older studios expose the resource catalogue directly. */ }
71
+ const index = await getJson(catalog, { fetch, what: 'the studio list of shared parts' }).catch(() => getJson(`${base}/.well-known/homie-parts.json`, { fetch, what: 'the older studio catalogue' })).catch((error) => { throw new Error(`${ref.host} shares no parts that can be read: ${error.message}`); });
72
+ let entry = (Array.isArray(index?.parts) ? index.parts : []).find((p) => p?.id === ref.id);
73
+ if (!entry && ref.version) {
74
+ const retired = await getJson(`${base}/parts/${ref.id}/${ref.version}/part.json`, { fetch, max: LIMITS.json, what: 'the previously sold release' });
75
+ if (retired.sale) entry = { ...retired, versions: [ref.version] };
76
+ }
64
77
  if (!entry) {
65
78
  const names = (Array.isArray(index?.parts) ? index.parts : []).map((p) => p?.id).filter(Boolean);
66
79
  throw new Error(`${ref.host} does not share a part "${ref.id}"${names.length ? ` (it shares: ${names.slice(0, 12).join(', ')})` : ' (it shares none)'}. A part its studio keeps private is not there to add.`);
@@ -75,16 +88,19 @@ export async function fetchPart(refText, { fetch } = {}) {
75
88
  if (part.id !== ref.id || part.version !== version) throw new Error(`${at}part.json says it is ${part.id} ${part.version}, not ${ref.id} ${version}`);
76
89
  if (part.share !== true) throw new Error(`${ref.ref} is not shared`);
77
90
  if (!Array.isArray(part.files) || !part.files.length) throw new Error(`${ref.ref} ${version} lists no files`);
91
+ const access = part.sale ? (onPaid ? await onPaid(ref, part, entry.releases?.find((r) => r.version === version)?.offer ?? part.sale) : null) : null;
92
+ if (part.sale && !access) throw new Error(`This is a paid part: ${priceWords(part.sale)}. Use part_add to review its price first.`);
78
93
  const files = new Map();
79
94
  let total = 0;
80
95
  for (const f of part.files) {
81
- const bytes = await get(`${at}${f.path.split('/').map(encodeURIComponent).join('/')}`, { fetch, max: Math.min(LIMITS.fileBytes, f.bytes), what: f.path });
96
+ const delivered = access?.files?.find((item) => item.path === f.path);
97
+ const bytes = delivered ? Buffer.from(delivered.data, 'base64') : await get(`${at}${f.path.split('/').map(encodeURIComponent).join('/')}`, { fetch, max: Math.min(LIMITS.fileBytes, f.bytes), what: f.path, authorization: access?.token });
82
98
  if (bytes.length !== f.bytes || sha256(bytes) !== f.sha256) throw new Error(`${ref.ref} ${version}: ${f.path} is not the file its part.json describes (its SHA-256 or size differs). Nothing was written; tell its studio.`);
83
99
  total += bytes.length;
84
100
  if (total > LIMITS.totalBytes) throw new Error(`${ref.ref} is over ${Math.round(LIMITS.totalBytes / 1048576)} MB`);
85
101
  files.set(f.path, bytes);
86
102
  }
87
- return { ref, part, files, entry, base, version, versions };
103
+ return { ref, part, files, entry, base, version, versions, access };
88
104
  }
89
105
 
90
106
  /* ------------------------------------------------------------------ packages: npm's */
@@ -124,10 +140,10 @@ export function ensurePackages(root, wants, { npm = runNpm, install = false, say
124
140
  if (ls.status === 0) { rows.push({ ...w, state: 'ok' }); continue; }
125
141
  const pinned = pinnedIn(root, w.name);
126
142
  if (pinned) { rows.push({ ...w, state: 'pinned', has: pinned, why: `${w.by} is built on ${w.name} ${w.range}; this studio's package.json has ${w.name} at ${pinned}, which npm says does not fit. Nothing was changed: move the studio to a version in that range if the part is worth it, or use a part written against ${pinned}.` }); continue; }
127
- const command = `npm install --save-exact ${spec}`;
143
+ const command = `npm install --ignore-scripts --save-exact ${spec}`;
128
144
  if (!install) { rows.push({ ...w, state: 'missing', command, why: `${w.by} is built on ${w.name} ${w.range}, which this studio does not have.` }); continue; }
129
145
  say(`Installing ${spec} from npm: ${w.by} is built on it.`);
130
- const r = npm(['install', '--save-exact', spec], { cwd: root });
146
+ const r = npm(['install', '--ignore-scripts', '--save-exact', spec], { cwd: root });
131
147
  const said = `${r.stdout}${r.stderr}`.trim();
132
148
  rows.push(r.status === 0 ? { ...w, state: 'installed', said } : { ...w, state: 'failed', said, command, why: `npm could not install ${spec} for ${w.by}. npm said:\n${said}` });
133
149
  }
@@ -153,7 +169,7 @@ export function partItems(root, { game = null, refs = null } = {}) {
153
169
  const dir = vendorDir(root, ref);
154
170
  let part = null;
155
171
  try { part = readPart(dir); } catch { part = null; }
156
- items.push({ ref, part, vendored: true, changed: part ? verifyFiles(dir, { files: e.files }, { skipTuning: true }).changed : [] });
172
+ items.push({ ref, part, vendored: true, purchase: e.purchase ? { ...(e.purchases?.[game] ?? e.purchase), ...purchaseFacts(root, ref, game) } : null, changed: part ? verifyFiles(dir, { files: e.files }, { skipTuning: true }).changed : [] });
157
173
  }
158
174
  for (const o of listOwnParts(root)) if (refs ? refs.includes(o.id) : !game) items.push({ ref: o.id, part: o.part, vendored: false });
159
175
  return items;
@@ -174,12 +190,19 @@ const diffFiles = (was, now) => {
174
190
  * told to (`overwrite`); only then write. tuning.json is the studio's from the first add and is never replaced: a
175
191
  * new version's defaults land beside it as tuning.upstream.json.
176
192
  */
177
- export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {} } = {}) {
193
+ export async function addPart(root, refText, { game = null, fetch, npm = runNpm, install = true, overwrite = false, say = () => {}, approve = null, quantity = 1, offline = false, wallet, walletPay } = {}) {
178
194
  const command = 'parts add';
179
195
  const fail = (why, extra = {}) => ({ ok: false, command, why, ...extra });
180
196
  if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
181
197
  let got;
182
- try { got = await fetchPart(refText, { fetch }); } catch (error) { return fail(error.message); }
198
+ try {
199
+ got = offline ? await offlinePurchase(root, refText, game) : await fetchPart(refText, { fetch, onPaid: (ref, part, offer) => paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay }) });
200
+ if (got.access) {
201
+ savePurchaseBackup(root, got, got.access, game);
202
+ const c = got.access.claims;
203
+ got.purchase = { version: c.version, mode: c.mode, scope: c.terms.scope, game: c.game, quantity: c.quantity, paidUntil: c.paidUntil, onExpiry: c.onExpiry, onRefund: c.onRefund, status: 'paid', checkedAt: new Date().toISOString() };
204
+ }
205
+ } catch (error) { return fail(error.message, error.purchase ? { purchase: error.purchase } : {}); }
183
206
  const { ref, part, files, version, base, entry } = got;
184
207
  const lock = readOrigins(root);
185
208
  const was = lock.parts[ref.ref] ?? null;
@@ -205,6 +228,7 @@ export async function addPart(root, refText, { game = null, fetch, npm = runNpm,
205
228
  const games = [...new Set([...(was?.games ?? []), ...(game ? [game] : [])])].sort();
206
229
  const from = isObj(entry.from) ? { game: line(entry.from.game, 40) || null, name: line(entry.from.name, 80) || null, studio: line(entry.from.studio, 80) || null, page: /^https:\/\//.test(String(entry.from.page ?? '')) ? String(entry.from.page).slice(0, 300) : null } : isObj(part.from) ? { game: part.from.game ?? null, studio: line(part.from.studio, 80) || null } : null;
207
230
  lock.parts[ref.ref] = {
231
+ ...(got.purchase ? { purchase: got.purchase, purchases: { ...(was?.purchases ?? {}), ...(got.purchase.scope === 'game' ? { [game]: got.purchase } : {}) } } : {}),
208
232
  version, url: `${base}/parts/${ref.id}/${version}/`, license: part.license ?? null, attribution: part.attribution ?? '', name: part.name, kind: part.kind,
209
233
  ...(from ? { from } : {}), page: `${base}/parts/${ref.id}/`, studio: line(got.entry?.studio?.name, 80) || null,
210
234
  files: part.files.map(({ path, sha256: h, bytes }) => ({ path, sha256: h, bytes })), games, addedAt: was?.addedAt ?? new Date().toISOString(), ...(was && !same ? { updatedAt: new Date().toISOString() } : {}),
@@ -224,7 +248,7 @@ export async function addPart(root, refText, { game = null, fetch, npm = runNpm,
224
248
  license: lic?.id ?? null, asks: lic?.asks ?? null, attribution: part.attribution ?? '', ...(from ? { from } : {}),
225
249
  files: part.files.length, bytes: part.files.reduce((n, f) => n + f.bytes, 0), verified: true,
226
250
  entry: part.entry ?? null, import: part.entry ? `@parts/${ref.ref}` : null, game, credits,
227
- packages, needs, licences,
251
+ packages, needs, licences, ...(got.purchase ? { purchase: got.purchase } : {}),
228
252
  ready: !blocked.length && !packages.some((p) => p.state === 'missing') && !needs.length && !licences.some((c) => c.level === 'conflict'),
229
253
  };
230
254
  }
@@ -289,8 +313,8 @@ export function sharePart(root, id, on = true) {
289
313
  const row = (p, extra) => ({
290
314
  id: p.id, name: p.name, kind: p.kind, version: p.version, summary: line(p.summary), license: p.license || null, tags: Array.isArray(p.tags) ? p.tags.slice(0, 12) : [],
291
315
  ...(Array.isArray(p.uses) ? { uses: p.uses } : {}),
292
- from: isObj(p.from) ? { ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
293
- cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
316
+ from: isObj(p.from) ? { ...(p.from.kind ? { kind: p.from.kind, id: p.from.id ?? null } : {}), ...(p.from.app ? { app: p.from.app, open: p.from.open ?? null } : {}), ...(p.from.music ? { music: p.from.music } : {}), ...(p.from.video ? { video: p.from.video } : {}), game: p.from.game ?? null, name: p.from.name ?? null, studio: p.from.studio ?? null, play: p.from.play ?? null } : null,
317
+ sale: p.sale ?? null, price: priceWords(p.sale), purchaseRequired: Boolean(p.sale), licenseTerms: p.licenseTerms ?? null, quality: p.quality ?? null, checkedAt: p.checkedAt ?? null, available: p.available ?? null, cost: isObj(p.cost) ? p.cost : {}, requires: isObj(p.requires) ? p.requires : {}, rig: p.skeleton?.rig ?? null, netplay: p.contract?.netplay ?? null, ...extra,
294
318
  });
295
319
  function matches(p, { words, kind, tag, license, builds }) {
296
320
  if (kind && p.kind !== kind) return false;
@@ -332,12 +356,19 @@ export async function findParts(root, query = '', { kind = null, tag = null, lic
332
356
  const have = new Set(here.filter((h) => h.where === 'here').map((h) => h.ref));
333
357
  found = index.parts.filter((p) => isObj(p) && typeof p.id === 'string' && (typeof p.host === 'string' || typeof p.add === 'string')).map((p) => {
334
358
  const ref = parseRef(typeof p.add === 'string' ? p.add : `${p.host}/${p.id}`)?.ref;
335
- return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `Add it: part_add { "part": "${ref}" }.` }) : null;
359
+ return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `${p.sale ? `Paid: ${priceWords(p.sale)}. Ask for approval of the exact quote before requesting payment. ` : 'Free. '}Add it: part_add { "part": "${ref}" }.` }) : null;
336
360
  }).filter(Boolean);
337
361
  hubState = { ok: true, url, parts: found.length };
338
362
  } catch (error) {
339
363
  hubState = { ok: false, url, why: `The catalogue of shared parts could not be read just now (${error.message}). This is NOT "no parts exist": only this studio's own parts were searched. Try again, or add a part straight from a studio you know (part_add with "<studio site>/<part id>").` };
340
364
  }
341
365
  const results = [...here.filter((p) => matches(p, filter)), ...found.filter((p) => !p.here && matches(p, filter))];
366
+ for (const p of results) {
367
+ p.relevance = words.reduce((n, w) => n + (String(p.name ?? '').toLowerCase().includes(w) ? 3 : 0) + (String(p.id).includes(w) ? 2 : 0) + (String(p.summary ?? '').toLowerCase().includes(w) ? 1 : 0), 0);
368
+ p.availability = p.available === false ? 'unavailable' : p.where === 'hub' && (!Number.isFinite(Date.parse(p.checkedAt)) || Date.parse(p.checkedAt) > Date.now() + 300000 || Date.now() - Date.parse(p.checkedAt) > 86400000) ? 'stale: verify with seller' : 'recently checked';
369
+ p.qualityMeaning = 'Seller-attested purchases are not independent payment verification; quality is informational and does not change ranking.';
370
+ }
371
+ results.sort((a, b) => b.relevance - a.relevance || (a.where === 'hub') - (b.where === 'hub') || Boolean(a.sale) - Boolean(b.sale) || String(a.ref ?? a.id).localeCompare(String(b.ref ?? b.id)));
372
+ for (const p of results) p.ranking = 'Text relevance first (name 3, id 2, summary 1 per matching word); ties: local first, then free before paid, then reference. Quality is informational.';
342
373
  return { ok: true, command: 'parts find', query: words.join(' '), filter: { kind, tag, license, builds }, hub: hubState, results, searched: { hub: found.length, here: here.length } };
343
374
  }