@homie-rocks/studio 0.37.1 → 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 (68) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/bin/homie-studio.mjs +4 -3
  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/purchase-links.mjs +42 -0
  13. package/lib/purchase-wallet.mjs +114 -0
  14. package/lib/scaffold.mjs +25 -5
  15. package/lib/shoot.mjs +1 -1
  16. package/lib/shop-links.mjs +15 -6
  17. package/lib/shop.mjs +91 -21
  18. package/lib/stripe-connect.mjs +2 -2
  19. package/lib/template-history.json +41 -0
  20. package/package.json +12 -2
  21. package/parts/COMMERCIAL-LICENCE.md +21 -0
  22. package/parts/PARTS.md +238 -10
  23. package/worker/extensions.mjs +13 -0
  24. package/worker/index.mjs +3 -2
  25. package/worker/machine-payments.mjs +72 -0
  26. package/worker/office.mjs +4 -1
  27. package/worker/parts-protected.mjs +111 -0
  28. package/worker/parts-purchase-catalogue.mjs +34 -0
  29. package/worker/parts-resource.mjs +32 -0
  30. package/worker/parts-sale.mjs +59 -0
  31. package/worker/parts.mjs +24 -11
  32. package/worker/payment-capabilities.mjs +70 -0
  33. package/worker/payment-dry-exchange.mjs +30 -0
  34. package/worker/payment-manual-refund.mjs +40 -0
  35. package/worker/payment-policy.mjs +40 -0
  36. package/worker/payment-recovery.mjs +163 -0
  37. package/worker/payment-test-exchange.mjs +19 -0
  38. package/worker/purchase-core.mjs +140 -0
  39. package/worker/purchase-crypto.mjs +17 -0
  40. package/worker/purchase-delivery.mjs +108 -0
  41. package/worker/purchase-discovery.mjs +26 -0
  42. package/worker/purchase-hosted-readiness.mjs +16 -0
  43. package/worker/purchase-keys.mjs +20 -0
  44. package/worker/purchase-links.mjs +9 -0
  45. package/worker/purchase-machine.mjs +279 -0
  46. package/worker/purchase-mcp.mjs +42 -0
  47. package/worker/purchase-office-routes.mjs +17 -0
  48. package/worker/purchase-office-script.mjs +38 -0
  49. package/worker/purchase-office.mjs +35 -0
  50. package/worker/purchase-owner.mjs +243 -0
  51. package/worker/purchase-pricing.mjs +8 -0
  52. package/worker/purchase-problem.mjs +4 -0
  53. package/worker/purchase-reconciliation.mjs +582 -0
  54. package/worker/purchase-response.mjs +82 -0
  55. package/worker/purchase-routes.mjs +611 -0
  56. package/worker/purchase-schema.mjs +50 -0
  57. package/worker/purchase-store.mjs +148 -0
  58. package/worker/purchases.mjs +6 -0
  59. package/worker/resource-kinds.mjs +20 -0
  60. package/worker/selling/index.mjs +8 -0
  61. package/worker/selling/runtime.mjs +42 -0
  62. package/worker/settlement-journal.mjs +138 -0
  63. package/worker/shop-page.mjs +7 -4
  64. package/worker/shop-refunds.mjs +22 -0
  65. package/worker/shop-rules.mjs +1 -1
  66. package/worker/shop.mjs +18 -18
  67. package/worker/stripe.mjs +36 -3
  68. package/worker/version.mjs +1 -1
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { purchasePortal } from './parts-purchase.mjs';
1
2
  /**
2
3
  * GAME PARTS AS CHAT TOOLS (parts/PARTS.md): parts_find, part_add, part_new and part_share, named and worded like
3
4
  * the asset tools beside them (assets_find, asset_add). Four tools are the whole surface: a newer version of a part
@@ -11,7 +12,7 @@ import { addPart, checkParts, findParts, sharePart } from './parts-store.mjs';
11
12
  import { partsLines } from './parts-cli.mjs';
12
13
 
13
14
  /** What the planning and game-making tools say about parts, in one place so a test can hold them to it. */
14
- export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of real games other studios shared: a creature from one game, a level from another, a bot brain from a third, each brought in with part_add. Record in the game\'s CODEX what came from where: which packages, which parts and the game and studio each came from, and what was written here and why. After building a piece another game could use, offer the person to make it a part (part_new lifts it out of the game) and, only if they ask, to share it (part_share).';
15
+ export const PARTS_FIRST = 'Before writing any system from scratch (camera, movement, bots, pickups, effects, sound, UI, environment), look for what exists, two different things: (1) the @homie-rocks/* packages on npm have the general mechanism (camera, input, audio, effects): use them; (2) `parts find` (the parts_find tool) searches the pieces of games, apps, music and video other studios shared: a creature from one game, a level from another, a bot brain from a third, each brought in with part_add. Record in the project notes or CODEX what came from where: which packages, which parts and the work and studio each came from, and what was written here and why. After building a reusable piece, offer the person to make it a part (part_new lifts it out of the game) and, only if they ask, to share it (part_share).';
15
16
 
16
17
  /** A fix-it line that names the plumbing, said as the chat tool that does the same. */
17
18
  export const inChat = (t) => String(t).replace(/homie-studio parts add (\S+?)(?:@(\S+))?(?: --game (\S+))?(?=[\s).,;]|$)/g, (m, ref, v, g) => `part_add { "part": "${ref}${v ? `@${v}` : ''}"${g ? `, "game": "${g}"` : ''} }`);
@@ -24,36 +25,38 @@ export function partsToolDefs(ctx, h) {
24
25
  {
25
26
  name: 'parts_find', title: 'Find game parts',
26
27
  description: 'Search the pieces of real games that studios shared (a creature, a level generator, a chase camera, a bot brain, a pickup mechanic, an audio pack), and this studio\'s own parts. The first move when planning or building a game: run it for each system the game needs before writing one from scratch, so a new game can be made with parts from several games. Each result has its licence, the game and studio it came from, its costs, what it builds on and what part_add takes. If the catalogue cannot be reached it says so (that is not "nothing exists"). Homie\'s @homie-rocks/* packages are not parts and are not listed here: they come from npm.',
27
- inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'Only this kind of part' }, tag: str('Only parts with this tag'), license: str('Only this licence (an SPDX identifier, e.g. CC0-1.0)'), builds: str('Only parts that build on this: a package ("@homie-rocks/camera") or a skeleton ("humanoid-v1")'), ...STUDIO_ARG } },
28
+ inputSchema: { type: 'object', properties: { query: str('Words for what the game needs: "chase camera", "pickups", "cave"'), kind: { type: 'string', description: 'Only this kind of part' }, tag: str('Only parts with this tag'), license: str('Only this licence (an SPDX identifier, e.g. CC0-1.0)'), builds: str('Only parts that build on this: a package ("@homie-rocks/camera") or a skeleton ("humanoid-v1")'), ...STUDIO_ARG } },
28
29
  annotations: { title: 'Find game parts', ...RO, openWorldHint: true },
29
30
  run: async (a) => {
30
31
  const root = ctx.root(a.studio, { need: false });
31
32
  const r = await findParts(root, String(a.query ?? ''), { kind: a.kind ?? null, tag: a.tag ?? null, license: a.license ?? null, builds: a.builds ?? null, fetch: net().fetch, hub: ctx.directory ?? null });
32
- return ok([text(r), '', r.results.length ? 'Tell the person what you found and which game each piece is from, with your pick; part_add brings one in. Write in the game\'s CODEX which parts it uses and what you wrote yourself and why.' : 'Nothing fits: write it, and say so in the game\'s CODEX. If it turns out reusable, offer to make it a part (part_new).'].join('\n'), { kind: 'parts', ...r });
33
+ return ok([text(r), '', r.results.length ? 'Tell the person what you found and which work and studio each piece is from, with your pick; part_add brings one in. Write in the project notes or CODEX which parts it uses and what you wrote yourself and why.' : 'Nothing fits: write it, and say so in the project notes or CODEX. If it turns out reusable, offer to make it a part (part_new).'].join('\n'), { kind: 'parts', ...r });
33
34
  },
34
35
  },
35
36
  {
36
- name: 'part_add', title: 'Add a game part',
37
- description: 'Bring a part another studio shared into this studio, for a game: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
38
- inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game that will use it (its credits get the line)'), overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
39
- annotations: { title: 'Add a game part', ...RW, openWorldHint: true },
37
+ name: 'part_add', title: 'Add a studio part',
38
+ description: 'Bring a part another studio shared into this studio, for a game or app, music or video: one exact version is fetched (the latest unless the part names one, "<studio site>/<part id>@1.2.0"), every file is checked against its SHA-256, and it is copied into parts/ where the studio owns and tunes it. The game credits it (its licence and who made it, on the game\'s credits page), and the packages it builds on are installed by npm. For a part that is already here it brings the newer version: what changed is shown, the studio\'s tuning is kept, and a file the studio edited is never replaced unless overwrite is true (ask the person first). The result says plainly when the licences of the game\'s parts cannot be combined, and gives the one import line.',
39
+ inputSchema: { type: 'object', properties: { part: str('"<studio site>/<part id>", as parts_find gives it; "@<version>" for one version'), game: str('The game or app that will use it (its credits get the line)'), approve: str('The exact quote hash returned by part_add, ONLY with owner or wallet authority covering its exact price, currency, quantity, recurrence and terms'), wallet: { type: 'string', enum: ['link-cli', 'tempo', 'purl'], description: 'The person’s installed wallet client; its authority and limits govern payment' }, quantity: { type: 'integer', minimum: 1, description: 'Quantity under the seller’s licence terms' }, offline: { type: 'boolean', description: 'Restore the verified private purchase backup without the seller; revocation status is only last known' }, manage: { type: 'boolean', description: 'Return the purchase Stripe portal link to manage or cancel a subscription' }, overwrite: { type: 'boolean', description: 'For a newer version: also replace files this studio edited. Only after the person agreed' }, ...STUDIO_ARG }, required: ['part'] },
40
+ annotations: { title: 'Add a studio part', ...RW, openWorldHint: true },
40
41
  run: async (a) => {
41
42
  const root = ctx.root(a.studio);
43
+ if (a.manage) { const r = await purchasePortal(root, String(a.part ?? ''), { game: a.game ?? null, ...net() }); return ok(r.message, { kind: 'part', ...r }); }
42
44
  const said = [];
43
- const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, say: (l) => said.push(l), ...net() });
45
+ const r = await addPart(root, String(a.part ?? ''), { game: a.game ? String(a.game) : null, overwrite: a.overwrite === true, approve: a.approve ?? null, quantity: a.quantity ?? 1, offline: a.offline === true, wallet: a.wallet, say: (l) => said.push(l), ...net() });
46
+ if (!r.ok && r.purchase) return ok(r.why, { kind: 'part', ...r, awaitingPurchase: true });
44
47
  if (!r.ok) return fail(`Not added: ${inChat(r.why)}`, { kind: 'part', ...r });
45
48
  const next = [];
46
49
  if (r.import) next.push(`In the game: import … from '${r.import}', then build. Its part.json says what it takes ("contract") and its tuning.json holds the values to tune.`);
47
50
  for (const n of r.needs) next.push(`It needs another part, ${n.ref}: part_add { "part": "${n.ref}"${a.game ? `, "game": "${a.game}"` : ''} }.`);
48
51
  if (r.licences.some((c) => c.level === 'conflict')) next.push('The licences cannot be combined (above): tell the person plainly before building on it.');
49
52
  if (r.packages.some((p) => p.state === 'pinned' || p.state === 'failed')) next.push('A package it builds on is not in place (above): the part will not build until it is.');
50
- return ok([...said, text(r), ...next, 'Write in the game\'s CODEX that it uses this part and which game and studio it came from.'].join('\n'), { kind: 'part', ...r });
53
+ return ok([...said, text(r), ...next, 'Write in the project notes or CODEX that it uses this part and which work and studio it came from.'].join('\n'), { kind: 'part', ...r });
51
54
  },
52
55
  },
53
56
  {
54
57
  name: 'part_new', title: 'Make a shared part',
55
- description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game\'s id) and files (paths inside that game: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
56
- inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', enum: [...PART_KINDS], description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
58
+ description: 'Make a part of this studio: a reusable piece of a game, app, music or video. With from (a game or app id) and files (paths inside that experience: a module or a folder), the files are LIFTED out of the game into parts/<id>/ and the game imports them from there, so it builds and behaves as before; a file that still reaches into the rest of the game is refused by name (pass what it needs in as an argument first). Without from, an empty part to write. A part is a reusable piece, never an entire experience, in whatever form suits it (code, assets, data, a tuned config). A new part is private. Offer this after building something reusable.',
59
+ inputSchema: { type: 'object', properties: { id: str('The part\'s id (lowercase, digits, hyphens): "chase-camera"'), kind: { type: 'string', description: 'What kind of part (default mechanic)' }, name: str('Its name'), uses: { type: 'array', items: { type: 'string', enum: ['game', 'app', 'venue', 'cause', 'music', 'video'] } }, from: str('The game or app to lift it out of'), files: { type: 'array', items: { type: 'string' }, description: 'With from: paths inside games/<from>/ or apps/<from>/ that become the part ("src/creature.ts", "src/cave")' }, ...STUDIO_ARG }, required: ['id'] },
57
60
  annotations: { title: 'Make a shared part', ...RW },
58
61
  run: async (a) => {
59
62
  const root = ctx.root(a.studio);
@@ -64,7 +67,7 @@ export function partsToolDefs(ctx, h) {
64
67
  },
65
68
  {
66
69
  name: 'part_share', title: 'Share a part',
67
- description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
70
+ description: 'Share one of this studio\'s parts with other studios, or stop sharing it. Only when the person asked: a part is private until they say otherwise. It needs a licence the person picked (an SPDX identifier: CC0-1.0, CC-BY-4.0, MIT…; with license it is set here), who to credit when the licence asks, and optional sale and licenseTerms fields in part.json for a paid licence, and known rights to every file: anything in the way is listed in plain words and nothing is shared. It is live after the studio\'s next deploy (studio_deploy), not before; stopping takes effect then too. With share left out it only checks and reports.',
68
71
  inputSchema: { type: 'object', properties: { id: str('The part\'s id'), share: { type: 'boolean', description: 'true: share it; false: stop sharing it; left out: only check what stands in the way' }, license: str('The licence the person picked, an SPDX identifier (set before sharing)'), attribution: str('Who a game using it should credit'), ...STUDIO_ARG }, required: ['id'] },
69
72
  annotations: { title: 'Share a part', ...RW },
70
73
  run: async (a) => {
@@ -0,0 +1,43 @@
1
+ /** Private R2 releases. Upload and verify before a deploy exposes the public offer. */
2
+ import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { listOwnParts, packedDir, packedVersions, publicPart, readPart, sha256, verifyFiles } from './parts.mjs';
6
+ import { objectKey, releaseKey } from "../worker/parts-sale.mjs";
7
+
8
+ export function paidReleases(root) {
9
+ return listOwnParts(root).flatMap(({ id }) => packedVersions(root, id).map((version) => {
10
+ const dir = packedDir(root, id, version); const part = readPart(dir);
11
+ return part.sale ? { dir, part: publicPart({ ...part, share: true }) } : null;
12
+ }).filter(Boolean));
13
+ }
14
+
15
+ /** The uploader uses the same Wrangler execution and read-back hashing as mediaMove. */
16
+ export async function uploadPaidParts(root, { bucket, put, hash, log = () => {} }) {
17
+ const releases = paidReleases(root);
18
+ if (!releases.length) return { releases: 0, files: 0 };
19
+ if (!bucket) throw new Error('Paid parts need private R2 storage. Set up storage with homie-studio storage add before deploying. Never enable a public bucket domain.');
20
+ const temp = mkdtempSync(join(tmpdir(), 'homie-parts-upload-')); const seen = new Set();
21
+ try {
22
+ const upload = async (key, path, expected) => {
23
+ const before = await hash(`${bucket}/${key}`);
24
+ if (before.code === 0 && before.sha256 === expected.sha256 && before.bytes === expected.bytes) return;
25
+ if (before.code === 0 && key.startsWith('paid-parts/releases/')) throw new Error(`${key} already exists with different terms. A release is immutable`);
26
+ const sent = await put(`${bucket}/${key}`, path);
27
+ if (sent.code !== 0) throw new Error(`Paid part upload failed for ${key}. Nothing may be offered until storage is verified`);
28
+ const got = await hash(`${bucket}/${key}`);
29
+ if (got.code !== 0 || got.sha256 !== expected.sha256 || got.bytes !== expected.bytes) throw new Error(`Paid part upload failed integrity verification for ${key}`);
30
+ };
31
+ for (const { dir, part } of releases) {
32
+ if (!verifyFiles(dir, part).ok) throw new Error(`Packed part ${part.id} ${part.version} no longer matches its files`);
33
+ for (const f of part.files) {
34
+ if (seen.has(f.sha256)) continue;
35
+ await upload(objectKey(f.sha256), join(dir, f.path), f); seen.add(f.sha256);
36
+ }
37
+ const bytes = Buffer.from(JSON.stringify(part)); const file = join(temp, 'part.json'); writeFileSync(file, bytes);
38
+ await upload(releaseKey(part.id, part.version), file, { sha256: sha256(bytes), bytes: bytes.length });
39
+ log(`verified private part ${part.id} ${part.version} in R2`);
40
+ }
41
+ return { releases: releases.length, files: seen.size };
42
+ } finally { rmSync(temp, { recursive: true, force: true }); }
43
+ }
package/lib/parts.mjs CHANGED
@@ -20,12 +20,17 @@ import { experienceDir, experienceFile } from './studio.mjs';
20
20
  * NOTHING HERE IS A PACKAGE MANAGER: packages are npm's (lib/parts-store.mjs hands them to npm), and a part is copied
21
21
  * in and then the studio's own. There are no version ranges, no resolver and no lockfile of Homie's.
22
22
  */
23
+ import { canonicalJson } from '../worker/referrals.mjs';
24
+ import { createRequire } from 'node:module';
25
+ const require = createRequire(import.meta.url);
26
+ const parseSpdx = (value) => require('spdx-expression-parse')(value);
27
+ import { saleProblems } from "../worker/parts-sale.mjs";
23
28
  import { createHash } from 'node:crypto';
24
29
  import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
25
30
  import { dirname, join, relative } from 'node:path';
26
31
 
27
32
  export const PART_ID = /^[a-z0-9][a-z0-9-]{0,47}$/;
28
- export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template']);
33
+ export const PART_KINDS = Object.freeze(['character', 'rig', 'clips', 'environment', 'effect', 'sound', 'ui', 'mechanic', 'shader', 'level-generator', 'bot-brain', 'audio-pack', 'set-piece', 'quiz', 'menu-board', 'waitlist', 'live-total', 'loop', 'stem', 'track', 'video-intro', 'video-template', 'app', 'music', 'video']);
29
34
  export const NETPLAY_KINDS = Object.freeze(['host-authoritative', 'replicated', 'local']);
30
35
  export const PROVENANCE = Object.freeze(['original', 'generated', 'imported']);
31
36
  export const PIVOTS = Object.freeze(['feet', 'centre', 'base', 'origin']);
@@ -107,7 +112,10 @@ export function licenseOfPart(value) {
107
112
  const id = typeof value === 'string' ? value.trim() : '';
108
113
  // Not an identifier at all; a word people write instead of one (cc0, proprietary); or a real
109
114
  // identifier in the wrong case (SPDX is case-sensitive, and "mit" must not pass as some unknown licence).
110
- if (!id || !SPDX_SHAPE.test(id)) return null;
115
+ if (!id) return null;
116
+ if (!SPDX_SHAPE.test(id)) {
117
+ try { parseSpdx(id); return { id, class: 'unrecognised', credit: true, asks: 'read this SPDX expression and its licence texts; no alternative was selected', known: false }; } catch { return null; }
118
+ }
111
119
  const cased = Object.values(CLASSES).flatMap((c) => c.ids).find((k) => k.toLowerCase() === id.toLowerCase());
112
120
  if (cased ? cased !== id : Object.hasOwn(NOT_SPDX, id.toLowerCase())) return null;
113
121
  for (const [name, c] of Object.entries(CLASSES)) if (c.ids.includes(id)) return { id, class: name, credit: c.credit, asks: c.asks, known: true };
@@ -231,7 +239,7 @@ export function checkPart(part, { id = null } = {}) {
231
239
  if (!PART_ID.test(String(part.id ?? ''))) bad('id', `"${String(part.id ?? '').slice(0, 60)}" is not a part id`, 'lowercase letters, digits and hyphens, starting with a letter or digit (chase-camera)');
232
240
  else if (id && part.id !== id) bad('id', `the folder is parts/${id} but part.json says "${part.id}"`, 'name them the same');
233
241
  if (!clean(part.name, 80)) bad('name', 'it has no name', 'a few words a person would search for ("Third-person chase camera")');
234
- if (!PART_KINDS.includes(part.kind)) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, `one of: ${PART_KINDS.join(', ')}`);
242
+ if (!(typeof part.kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(part.kind))) bad('kind', part.kind === undefined ? 'it has no kind' : `"${String(part.kind).slice(0, 40)}" is not a kind of part`, 'a lowercase label using letters, digits and hyphens');
235
243
  if (!isVersion(part.version)) bad('version', part.version === undefined ? 'it has no version' : `"${String(part.version).slice(0, 40)}" is not a version`, 'three numbers: 1.0.0');
236
244
  // A part being written has no summary yet; one that is shared must (shareProblems).
237
245
  if (!clean(part.summary)) warn('summary', 'it has no summary', 'one sentence: what it does and what it needs from a game');
@@ -239,6 +247,8 @@ export function checkPart(part, { id = null } = {}) {
239
247
  if (part.share !== undefined && typeof part.share !== 'boolean') bad('share', 'it is neither true nor false', 'false keeps the part private (the default); sharing the part sets it true');
240
248
  if (part.license !== undefined && part.license !== null && part.license !== '' && !licenseOfPart(part.license)) bad('license', 'it is not a licence a stranger can rely on', licenceHint(part.license));
241
249
  if (part.attribution !== undefined && typeof part.attribution !== 'string') bad('attribution', 'it is not text', 'the line a game using the part shows in its credits, or ""');
250
+ for (const problem of saleProblems(part)) bad('sale', problem);
251
+ if (part.licenseTerms !== undefined && !safePath(part.licenseTerms)) bad('licenseTerms', 'must be a file inside the part');
242
252
  if (part.tags !== undefined) {
243
253
  if (!Array.isArray(part.tags) || part.tags.some((t) => typeof t !== 'string' || !/^[a-z0-9][a-z0-9-]{0,29}$/.test(t))) bad('tags', 'a tag is not a lowercase word', 'up to 12 words such as "camera", "3d", "top-down"');
244
254
  else if (part.tags.length > 12) warn('tags', 'more than 12 tags; the first 12 are used', null);
@@ -444,6 +454,24 @@ export function packPart(root, id, { bump = null } = {}) {
444
454
  const dir = partDir(root, id);
445
455
  let part = readPart(dir);
446
456
  if (bump) { part.version = bumpVersion(part.version, bump); writePart(dir, part); }
457
+ // REUSE places custom licence texts under LICENSES and annotates binary files.
458
+ if (part.sale && part.licenseTerms && safePath(part.licenseTerms) && existsSync(join(dir, part.licenseTerms)) && !existsSync(packedDir(root, id, part.version))) {
459
+ const refs = [...new Set(String(part.license).match(/LicenseRef-[A-Za-z0-9.-]+/g) ?? [])];
460
+ if (refs.length > 1 && refs.some((ref) => !existsSync(join(dir, 'LICENSES', `${ref}.txt`)))) return { ok: false, command: 'parts pack', id, why: 'An expression with multiple custom licences needs each full text under LICENSES before packing' };
461
+ for (const ref of refs) {
462
+ const target = join(dir, 'LICENSES', `${ref}.txt`);
463
+ mkdirSync(dirname(target), { recursive: true });
464
+ if (!existsSync(target)) cpSync(join(dir, part.licenseTerms), target);
465
+ }
466
+ if (!existsSync(join(dir, 'REUSE.toml'))) writeFileSync(join(dir, 'REUSE.toml'), `version = 1
467
+
468
+ [[annotations]]
469
+ path = ["**"]
470
+ precedence = "aggregate"
471
+ SPDX-FileCopyrightText = ${JSON.stringify(part.attribution || 'The selling studio')}
472
+ SPDX-License-Identifier = ${JSON.stringify(part.license)}
473
+ `);
474
+ }
447
475
  part = writeHashes(dir);
448
476
  const shape = checkPart(part, { id });
449
477
  if (!shape.ok) return { ok: false, command: 'parts pack', id, why: `parts/${id} is not a part yet: ${shape.problems.filter((p) => p.level === 'refuse').map((p) => `${p.field}: ${p.problem}${p.fix ? ` (${p.fix})` : ''}`).slice(0, 4).join('; ')}`, problems: shape.problems };
@@ -451,6 +479,9 @@ export function packPart(root, id, { bump = null } = {}) {
451
479
  if (existsSync(out)) {
452
480
  const was = readPart(out);
453
481
  const same = JSON.stringify((was.files ?? []).map((f) => [f.path, f.sha256])) === JSON.stringify(part.files.map((f) => [f.path, f.sha256]));
482
+ if ((was.sale || part.sale) && canonicalJson(publicPart({ ...was, share: true })) !== canonicalJson(publicPart({ ...part, share: true }))) return { ok: false, command: 'parts pack', id, why: 'A paid release is immutable, including its description, price and licence. Give the change a new version.' };
483
+ const legalSame = JSON.stringify({ license: was.license, licenseTerms: was.licenseTerms, sale: was.sale }) === JSON.stringify({ license: part.license, licenseTerms: part.licenseTerms, sale: part.sale });
484
+ if (!legalSame) return { ok: false, command: 'parts pack', id, why: 'A packed version cannot change its price or licence terms. Give it a new version.' };
454
485
  if (!same) return { ok: false, command: 'parts pack', id, version: part.version, why: `parts/${id} ${part.version} is already packed with different files, and a shared version never changes (another studio may have copied it). Give the change a new version: raise "version" in parts/${id}/part.json, then share it again.` };
455
486
  // The same bytes: only the description may have moved (a summary, `share`), so the frozen copy takes it.
456
487
  writePart(out, part);
@@ -566,7 +597,7 @@ function liftPlan(root, game, paths) {
566
597
  */
567
598
  export function newPart(root, id, { kind = 'mechanic', name = null, from = null, paths = [], uses = [] } = {}) {
568
599
  if (!PART_ID.test(String(id ?? ''))) return { ok: false, command: 'parts new', why: `"${id ?? ''}" is not a part id: lowercase letters, digits and hyphens (chase-camera)` };
569
- if (!PART_KINDS.includes(kind)) return { ok: false, command: 'parts new', why: `"${kind}" is not a kind of part: ${PART_KINDS.join(', ')}` };
600
+ if (!(typeof kind === 'string' && /^[a-z0-9][a-z0-9-]*$/.test(kind))) return { ok: false, command: 'parts new', why: `"${kind}" is not a lowercase kind label` };
570
601
  if (!Array.isArray(uses) || uses.some((u) => !['game', 'app', 'venue', 'cause', 'music', 'video'].includes(u))) return { ok: false, command: 'parts new', why: 'invalid intended uses' };
571
602
  const dir = partDir(root, id);
572
603
  if (existsSync(dir)) return { ok: false, command: 'parts new', why: `parts/${id} is there already` };
@@ -653,6 +684,19 @@ export function licenceIssues(items, { game = null } = {}) {
653
684
  const add = (level, parts, problem, fix = null) => out.push({ level, parts, problem, fix });
654
685
  const sa = new Map();
655
686
  for (const x of items.filter((y) => isObj(y.part))) {
687
+ if (x.vendored && x.part.sale) {
688
+ const s = x.part.sale; const p = x.purchase;
689
+ if (!p) add('conflict', [x.ref], 'No purchase evidence is recorded for this paid part', 'add it through part_add');
690
+ else {
691
+ if (p.version !== x.part.version) add('conflict', [x.ref], 'This game has no recorded grant for the installed paid release', 'call part_add for this game and version to check its update rights');
692
+ if (p.scope === 'game' && game && p.game !== game.id) add('conflict', [x.ref], `This licence covers game ${p.game}, not ${game.id}`, 'buy a licence for this game');
693
+ if (p.paidUntil && p.paidUntil <= Date.now() && s.onExpiry === 'terminate') add('conflict', [x.ref], 'The subscription licence expired', 'renew through the seller');
694
+ if (['refunded', 'lost'].includes(p.status) && s.onRefund === 'terminate') add('conflict', [x.ref], 'This purchase was revoked under its refund terms', 'settle the licence before publishing');
695
+ add('note', [x.ref], `${p.mode} purchase; ${p.scope} scope${p.scope === 'seat' ? `, ${p.quantity} seats` : ''}; last checked ${p.checkedAt ?? 'unknown'}. Offline evidence cannot prove current refund status.`);
696
+ }
697
+ if (game?.sells && !s.commercialUse) add('conflict', [x.ref], 'This paid licence excludes commercial use', 'obtain commercial rights before selling');
698
+ add('note', [x.ref], `Read ${x.part.licenseTerms}: source ${s.source ? 'included' : 'not included'}, transfer ${s.transferable ? 'permitted under the written terms' : 'requires a new licence'}. Publishing a game does not grant standalone part redistribution rights.`);
699
+ }
656
700
  const lic = licenseOfPart(x.part.license);
657
701
  if (!lic) { if (x.vendored) add('conflict', [x.ref], 'it names no licence, so nothing says a game may use it', 'ask its studio, or use another part'); continue; }
658
702
  if (lic.class === 'unrecognised') add('warn', [x.ref], `${lic.id} is not a licence the toolkit can reason about`, 'read it with the person before the game goes online');
@@ -677,7 +721,7 @@ export function creditLine(ref, entry) {
677
721
  url: entry.page ?? null,
678
722
  licence: lic?.id ?? null,
679
723
  licenceUrl: lic?.known ? `https://spdx.org/licenses/${lic.id}.html` : null,
680
- note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A game part from ${ref.split('/')[0]}`,
724
+ note: entry.from?.name || entry.from?.game ? `A part of ${entry.from.name ?? entry.from.game}, from ${ref.split('/')[0]}` : `A studio part from ${ref.split('/')[0]}`,
681
725
  part: ref,
682
726
  };
683
727
  }
@@ -0,0 +1,42 @@
1
+ /** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { sharedPartsOf, readPart, packedDir } from './parts.mjs';
6
+ import { canonicalJson } from '../worker/referrals.mjs';
7
+ import { paymentLinkURL } from '../worker/shop-links.mjs';
8
+ const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
9
+ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, till }) {
10
+ const file = join(root, 'parts', 'offers.json');
11
+ const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
12
+ const offers = sharedPartsOf(root).flatMap(({id, versions, part}) => versions.flatMap((version) => {
13
+ const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
14
+ return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
15
+ }));
16
+ const configured = offers.flatMap(o => {
17
+ const quantities = settings[o.resource]?.quantities ?? [1];
18
+ if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
19
+ return [...new Set(quantities)].map(quantity=>({...o,quantity}));
20
+ });
21
+ const links = (await list('/v1/payment_links')).filter((l) => l.metadata?.homie === 'purchase-links-v1' && l.metadata?.studio === slug && l.metadata?.origin === site);
22
+ const config = {v:1,mode,offers:[]}, keep = new Set();
23
+ for (const o of configured) {
24
+ const revision = hash({o,till});
25
+ let link = links.find((l)=>l.metadata?.revision === revision);
26
+ if (!link) {
27
+ const metadata = {homie:'purchase-links-v1',studio:slug,origin:site,revision};
28
+ const product = (await list('/v1/products')).find(p=>p.metadata?.homie === 'purchase-links-v1' && p.metadata?.studio === slug && p.metadata?.origin === site && p.metadata?.revision === revision) ?? await api('POST','/v1/products',{name:`${o.name} ${o.version}`, ...(o.sale.taxCode ? {tax_code:o.sale.taxCode}:{}),metadata},`purchase-product-${revision}`);
29
+ const price = (await list('/v1/prices')).find(p=>p.product === product.id && p.metadata?.revision === revision) ?? await api('POST','/v1/prices',{product:product.id,currency:o.sale.currency,unit_amount:o.sale.amount,...(o.sale.taxBehavior ? {tax_behavior:o.sale.taxBehavior}:{}),...(o.sale.billing !== 'one-time' ? {recurring:{interval:o.sale.billing}}:{}),metadata},`purchase-price-${revision}`);
30
+ link = await api('POST','/v1/payment_links',{line_items:[{price:price.id,quantity:o.quantity}],metadata,
31
+ ...(o.sale.billing === 'one-time' ? {payment_intent_data:{metadata}}:{subscription_data:{metadata}}),
32
+ ...(till === 'stripe-managed' ? {managed_payments:{enabled:true}} : {automatic_tax:{enabled:o.sale.automaticTax === true},...(o.sale.taxIdCollection?{tax_id_collection:{enabled:true}}:{}),...(o.sale.billing === 'one-time' && o.sale.invoiceCreation?{invoice_creation:{enabled:true}}:{}),...(o.sale.adaptivePricing === undefined?{}:{adaptive_pricing:{enabled:o.sale.adaptivePricing}})}),
33
+ ...(o.sale.billing === 'one-time' && o.sale.customerCreation?{customer_creation:'always'}:{}),allow_promotion_codes:o.sale.promotionCodes === true,
34
+ after_completion:{type:'redirect',redirect:{url:`${site}/purchases/thanks`}}},`purchase-link-${revision}`);
35
+ }
36
+ if (!paymentLinkURL(link.url)) throw new Error('Stripe returned an invalid Payment Link');
37
+ if (!link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:true});
38
+ keep.add(link.id);
39
+ config.offers.push({kind:o.kind,resource:o.resource,version:o.version,quantity:o.quantity,offer:canonicalJson(o.sale),id:link.id,url:link.url,revision});
40
+ }
41
+ return { config, retire:async()=>{ for (const l of links) if(l.active && !keep.has(l.id)) await api('POST',`/v1/payment_links/${l.id}`,{active:false}); } };
42
+ }
@@ -0,0 +1,114 @@
1
+ /** Payment authority and limits stay in the person's existing wallet client. */
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ const execute = promisify(execFile);
5
+ export async function payWithWallet(
6
+ url,
7
+ body,
8
+ { client = process.env.HOMIE_PARTS_WALLET, pending, run = execute } = {},
9
+ ) {
10
+ if (!client) return null;
11
+ if (!["link-cli", "purl", "tempo"].includes(client))
12
+ return { data: { detail: "Choose link-cli, purl or tempo as the wallet; hosted Checkout is available." } };
13
+ let args;
14
+ if (client === "link-cli") {
15
+ args = [
16
+ "mpp",
17
+ "pay",
18
+ url,
19
+ "-X",
20
+ "POST",
21
+ "-d",
22
+ JSON.stringify(body),
23
+ "--format",
24
+ "json",
25
+ ];
26
+ if (pending?.id)
27
+ args.push(
28
+ "--spend-request-id",
29
+ pending.id,
30
+ "--approved-challenge",
31
+ pending.challenge,
32
+ );
33
+ else
34
+ args.push(
35
+ "--amount",
36
+ String(body.amount),
37
+ "--context",
38
+ `Purchase the digital resource ${body.resource} version ${body.version} from ${new URL(url).host} under the exact price and licence terms approved in this conversation. The selling studio delivers the files and signed purchase proof.`,
39
+ );
40
+ } else {
41
+ const atomic = BigInt(body.amount) * 10000n;
42
+ const purlLimit = process.env.PURL_MAX_AMOUNT;
43
+ if (purlLimit && !/^\d+$/.test(purlLimit))
44
+ return { data: { detail: "Invalid wallet maximum amount; use hosted Checkout or correct the wallet setting." } };
45
+ const tempoLimit = process.env.TEMPO_MAX_SPEND;
46
+ if (
47
+ tempoLimit &&
48
+ (!Number.isFinite(Number(tempoLimit)) || Number(tempoLimit) < 0)
49
+ )
50
+ return { data: { detail: "Invalid wallet maximum spend; use hosted Checkout or correct the wallet setting." } };
51
+ // Use the wallet's native per-request ceiling in its documented units.
52
+ const ceiling =
53
+ client === "tempo"
54
+ ? [
55
+ "--max-spend",
56
+ String(
57
+ Math.min(
58
+ body.amount / 100,
59
+ tempoLimit ? Number(tempoLimit) : Infinity,
60
+ ),
61
+ ),
62
+ ]
63
+ : [
64
+ "--max-amount",
65
+ String(
66
+ purlLimit && BigInt(purlLimit) < atomic
67
+ ? BigInt(purlLimit)
68
+ : atomic,
69
+ ),
70
+ "--network",
71
+ "base,base-sepolia",
72
+ "--output-format",
73
+ "json",
74
+ ];
75
+ args = [
76
+ ...(client === "tempo" ? ["request"] : []),
77
+ ...ceiling,
78
+ "-X",
79
+ "POST",
80
+ "--json",
81
+ JSON.stringify(body),
82
+ url,
83
+ ];
84
+ }
85
+ let stdout;
86
+ try { ({ stdout } = await run(client, args, {
87
+ encoding: "utf8",
88
+ maxBuffer: 256 * 1024 * 1024,
89
+ timeout: 60000,
90
+ })); } catch (error) {
91
+ if (error.code === 'ENOENT') return { data: { detail: `The ${client} wallet is not installed; use hosted Checkout.` } };
92
+ // Wallets often return a structured refusal with a nonzero exit status.
93
+ // A process timeout or broken output is ambiguous: never start a second payment.
94
+ if (error.stdout?.trim()) stdout = error.stdout;
95
+ else if (/declined|insufficient funds|spend limit|not authenticated|not logged in|unsupported (network|method)|user rejected|user denied/i.test(error.stderr ?? ''))
96
+ return { data: { detail: String(error.stderr).trim(), paymentStatus: 'not_paid' } };
97
+ else return { uncertain: true, data: { detail: 'The wallet stopped without a payment result. Retry this purchase to reconcile it; do not pay again.' } };
98
+ }
99
+ let result;
100
+ try { result = JSON.parse(stdout); }
101
+ catch { return { uncertain: true, data: { detail: 'The wallet returned an unreadable payment result. Retry this purchase to reconcile it; do not pay again.' } }; }
102
+ if (result._next?.pay_argv) {
103
+ const next = result._next.pay_argv.args;
104
+ return {
105
+ pending: {
106
+ id: result.id,
107
+ challenge: next[next.indexOf("--approved-challenge") + 1],
108
+ },
109
+ approval: result.approval_url,
110
+ };
111
+ }
112
+ const value = result.body ?? result;
113
+ return { data: typeof value === "string" ? JSON.parse(value) : value };
114
+ }