@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.
- package/CHANGELOG.md +46 -0
- package/bin/homie-studio.mjs +10 -3
- package/lib/cloudflare.mjs +40 -6
- package/lib/dev.mjs +1 -1
- package/lib/doctor.mjs +7 -7
- package/lib/domain.mjs +43 -0
- package/lib/jobs.mjs +17 -7
- package/lib/mcp-tools.mjs +49 -18
- package/lib/mcp.mjs +6 -11
- package/lib/parts-build.mjs +23 -4
- package/lib/parts-cli.mjs +54 -3
- package/lib/parts-purchase.mjs +174 -0
- package/lib/parts-store.mjs +50 -19
- package/lib/parts-tools.mjs +15 -12
- package/lib/parts-upload.mjs +43 -0
- package/lib/parts.mjs +49 -5
- package/lib/prepare.mjs +47 -0
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/scaffold.mjs +35 -5
- package/lib/shoot.mjs +1 -1
- package/lib/shop-links.mjs +15 -6
- package/lib/shop.mjs +91 -21
- package/lib/stripe-connect.mjs +2 -2
- package/lib/template-history.json +82 -0
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/worker/extensions.mjs +13 -0
- package/worker/index.mjs +3 -2
- package/worker/machine-payments.mjs +72 -0
- package/worker/office.mjs +4 -1
- package/worker/parts-protected.mjs +111 -0
- package/worker/parts-purchase-catalogue.mjs +34 -0
- package/worker/parts-resource.mjs +32 -0
- package/worker/parts-sale.mjs +59 -0
- package/worker/parts.mjs +24 -11
- package/worker/payment-capabilities.mjs +70 -0
- package/worker/payment-dry-exchange.mjs +30 -0
- package/worker/payment-manual-refund.mjs +40 -0
- package/worker/payment-policy.mjs +40 -0
- package/worker/payment-recovery.mjs +163 -0
- package/worker/payment-test-exchange.mjs +19 -0
- package/worker/purchase-core.mjs +140 -0
- package/worker/purchase-crypto.mjs +17 -0
- package/worker/purchase-delivery.mjs +108 -0
- package/worker/purchase-discovery.mjs +26 -0
- package/worker/purchase-hosted-readiness.mjs +16 -0
- package/worker/purchase-keys.mjs +20 -0
- package/worker/purchase-links.mjs +9 -0
- package/worker/purchase-machine.mjs +279 -0
- package/worker/purchase-mcp.mjs +42 -0
- package/worker/purchase-office-routes.mjs +17 -0
- package/worker/purchase-office-script.mjs +38 -0
- package/worker/purchase-office.mjs +35 -0
- package/worker/purchase-owner.mjs +243 -0
- package/worker/purchase-pricing.mjs +8 -0
- package/worker/purchase-problem.mjs +4 -0
- package/worker/purchase-reconciliation.mjs +582 -0
- package/worker/purchase-response.mjs +82 -0
- package/worker/purchase-routes.mjs +611 -0
- package/worker/purchase-schema.mjs +50 -0
- package/worker/purchase-store.mjs +148 -0
- package/worker/purchases.mjs +6 -0
- package/worker/resource-kinds.mjs +20 -0
- package/worker/selling/index.mjs +8 -0
- package/worker/selling/runtime.mjs +42 -0
- package/worker/settlement-journal.mjs +138 -0
- package/worker/shop-page.mjs +7 -4
- package/worker/shop-refunds.mjs +22 -0
- package/worker/shop-rules.mjs +1 -1
- package/worker/shop.mjs +18 -18
- package/worker/stripe.mjs +36 -3
- 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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
|
package/lib/parts-build.mjs
CHANGED
|
@@ -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-
|
|
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 '
|
|
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
|
+
}
|
package/lib/parts-store.mjs
CHANGED
|
@@ -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
|
|
45
|
-
if (
|
|
46
|
-
return
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
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 {
|
|
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
|
}
|