@homie-rocks/studio 0.37.1 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/bin/homie-studio.mjs +7 -6
- package/lib/cloudflare.mjs +28 -3
- package/lib/dev.mjs +1 -1
- 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/perf.mjs +17 -10
- package/lib/port-check.mjs +77 -18
- package/lib/purchase-links.mjs +42 -0
- package/lib/purchase-wallet.mjs +114 -0
- package/lib/rules-check-worker.mjs +15 -2
- package/lib/scaffold.mjs +25 -5
- package/lib/shoot.mjs +12 -6
- 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 +85 -0
- package/netplay/NETPLAY.md +20 -6
- package/netplay/netplay.ts +3 -1
- package/package.json +12 -2
- package/parts/COMMERCIAL-LICENCE.md +21 -0
- package/parts/PARTS.md +238 -10
- package/port/probe.ts +2 -1
- package/rules/core.ts +3 -1
- package/rules/host.ts +3 -1
- package/rules/pack.ts +30 -3
- package/rules/view.ts +232 -40
- package/starters/coin-dash/lab.json +17 -0
- package/starters/coin-dash/src/rules.ts +3 -3
- package/starters/gem-rush/game.json +19 -4
- package/starters/gem-rush/map/main.json +50 -0
- package/starters/gem-rush/src/move.ts +23 -0
- package/starters/gem-rush/src/rules.ts +118 -0
- package/starters/gem-rush/src/view.ts +429 -0
- package/starters/gem-rush/tunables.json +98 -11
- 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/starters/gem-rush/src/main.ts +0 -1087
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,66 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.38.0] - 2026-10-09
|
|
21
|
+
|
|
22
|
+
**Plugin 0.39.0**
|
|
23
|
+
|
|
24
|
+
Your own character answers as soon as you press, even when the room runs on the server.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Rules games predict your own movement with the same movement code the host runs. They ease corrections from the host and smoothly draw everyone else. Authors get this through the view library.
|
|
29
|
+
- The build checks that browser movement agrees with the server and stays within its budget.
|
|
30
|
+
|
|
31
|
+
### Changed
|
|
32
|
+
|
|
33
|
+
- New Gem Rush games use rules plus view, with the server keeping movement, pickups and scores. The canvas, controls, waves and knockback keep their familiar look and play. Coin Dash also uses server movement with prediction.
|
|
34
|
+
|
|
35
|
+
### Fixed
|
|
36
|
+
|
|
37
|
+
- Rules prediction keeps movement continuous when snapshots replace its catch-up path or its input clock recalibrates, fixing backward steps on delayed, lossy connections.
|
|
38
|
+
- A rules correction that catches up to the current fractional tick uses the next movement preview instead of holding still and jumping when the next whole tick arrives. Snapshots also preserve the drawn catch-up pose when they replace its history. Offset fading is bounded by normal movement speed, so it does not compound a fast knock's catch-up speed.
|
|
39
|
+
- Gem Rush reports its downward Y axis to the rules probe, so control checks judge its actual on-screen direction. Rules views can supply their camera axes through `screenBasis`.
|
|
40
|
+
- Playtest no longer attributes its default timeout duration to a rules game that declares no round duration in `game.json`.
|
|
41
|
+
- Perf checks body presence in the last drawn frame without imposing a hidden 400 ms frame-rate requirement on software rendering.
|
|
42
|
+
- Studio checks accept two replicas when the server hosts the rules. Perf reports and regression guards include both replicas, and choose a replica as the default goal for server rooms.
|
|
43
|
+
- Shoot sends held keys to the game frame and reports a server-only preview without a running room as not playing. Its saved receipt now includes the final verdict.
|
|
44
|
+
- The Game Lab exits after writing its completed report, even when Chrome leaves a pipe open. Coin Dash includes a reproducible Lab take.
|
|
45
|
+
- Control timing starts at the game frame's receipt of a key or completed thumb gesture, judges the first changed frame, collects enough frames on a slow renderer, and measures the round clock between matching observations (including slow screenshots). The response and steering limits are unchanged.
|
|
46
|
+
- The controls check exercises a browser disconnect and late joining in server rooms as well as host takeover in browser rooms. A hold with too little runway is repeated after moving away from the wall, with the same steering requirements.
|
|
47
|
+
|
|
48
|
+
### Upgrade notes
|
|
49
|
+
|
|
50
|
+
- Existing games keep their own source when you upgrade. Games written the old way keep working.
|
|
51
|
+
|
|
52
|
+
## [0.37.2] - 2026-10-09
|
|
53
|
+
|
|
54
|
+
**Plugin 0.38.2** · [#70](https://github.com/homie-rocks/homie/pull/70)
|
|
55
|
+
|
|
56
|
+
Sell game, app, music or video parts on your own studio's site, with your price and licence; another studio's AI can discover, pay for and install them.
|
|
57
|
+
|
|
58
|
+
### Added
|
|
59
|
+
|
|
60
|
+
- **Your own part shop.** Tell your AI which files to sell, their price and their licence. On a studio already deployed with Stripe, connection takes one browser approval when needed (zero if already connected); the AI creates Payment Links and the webhook in your account. It generates and installs the purchase signing key too. Nobody finds or pastes a key for this setup.
|
|
61
|
+
- **An AI can pay without a checkout page.** MPP with Stripe shared payment tokens, x402 on Base, and MPP over MCP return verified files and a signed proof. The buyer approves the exact price or gives its wallet standing authority. Wallet-required approvals still apply. Stripe's hosted checkout is the fallback.
|
|
62
|
+
- **Machine payments are an optional fuller connection.** Stripe's shared-token charge and stablecoin PaymentIntent recording APIs need a Worker credential; the MCP binding uses the same charge. Your AI can operate Stripe's key-creation page and transfer its Copy result privately with `shop connect --from-clipboard`; you only handle Stripe's sign-in, verification and approval prompts. Live Base also needs your CDP facilitator credentials. The guide distinguishes this setup from the one-approval keyless default.
|
|
63
|
+
- Receipts, retries, private storage, refunds and payment recovery belong to your studio. **homie.rocks is not in the money path**, takes no fee, and can be unavailable while a buyer buys directly from your site.
|
|
64
|
+
|
|
65
|
+
### Changed
|
|
66
|
+
|
|
67
|
+
- Part kinds are labels you choose; app, music and video are suggestions alongside existing game kinds. Existing manifests and game imports still work. App pieces use the same extraction, imports and credits as games; music and video retain their provenance and intended uses.
|
|
68
|
+
- Sellers choose licence scopes, refund windows, approval-intent lifetimes and recurring intervals supported by Stripe. No new price ceiling, seller kind list or spending cap. Turnstile on the hosted fallback is optional; request flood protection remains configurable.
|
|
69
|
+
- Paid resources use the shop's webhook verification and refund-list reader, including keyless signed refund snapshots. Each purchase keeps its frozen terms and private claim. A second purchase has a fresh claim; retries keep the first claim and never request a second charge.
|
|
70
|
+
- Selling code is loaded only by the selling entry. A studio that sells no parts keeps the ordinary Worker and its existing compatibility flags.
|
|
71
|
+
|
|
72
|
+
### Upgrade notes
|
|
73
|
+
|
|
74
|
+
- Follows [#74](https://github.com/homie-rocks/homie/pull/74), studio 0.37.1 / plugin 0.38.1. Main’s apps, keyless shop and visible Shop control remain available.
|
|
75
|
+
|
|
76
|
+
- The AI applies `0015_purchases.sql` and `0016_purchase_facts.sql`, configures private storage, and uploads and verifies paid files when you deploy. Existing shop migrations stay unchanged. Keep paid files and the seller's signing key backed up.
|
|
77
|
+
- Payment Links need no Stripe API key in the Worker. A keyless refund is made in Stripe or by the owner's connected AI; signed events update the studio. Missing events wait for Stripe redelivery. Dynamic quantities and provider-read recovery use the fuller connection.
|
|
78
|
+
- Verification uses stand-in payment services in the local Workers runtime and Chrome. No real payment account was signed into and nothing was deployed. Actual account eligibility, settlement and provider permissions still need verification on the seller's account.
|
|
79
|
+
|
|
20
80
|
## [0.37.1] - 2026-10-09
|
|
21
81
|
|
|
22
82
|
**Plugin 0.38.1** · [#74](https://github.com/homie-rocks/homie/pull/74)
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -336,7 +336,7 @@ import { publish, publishBefore } from '../lib/directory.mjs';
|
|
|
336
336
|
import { look } from '../lib/look.mjs';
|
|
337
337
|
import { importPort, planPort } from '../lib/port.mjs';
|
|
338
338
|
import { portCheck } from '../lib/port-check.mjs';
|
|
339
|
-
import {
|
|
339
|
+
import { perfCompare, perfRun, perfSizes } from '../lib/perf.mjs';
|
|
340
340
|
import { LAB_PORT, labServe, labSet, labStop } from '../lib/lab.mjs';
|
|
341
341
|
import { R2_COST, lineOf, mediaPlan, r2OverOf, recordUpload, resolveMedia, sizeOf, typeOf } from '../lib/media.mjs';
|
|
342
342
|
import { newStudio } from '../lib/scaffold.mjs';
|
|
@@ -819,7 +819,8 @@ const OUTWARD_FLAGS = {
|
|
|
819
819
|
'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
|
|
820
820
|
'chat remove': ['url', 'all'], 'chat budget': ['url'],
|
|
821
821
|
'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
|
|
822
|
-
'
|
|
822
|
+
'parts reissue': ['claim-hash', 'receipt-verified', 'buyer'], 'parts retire': [], 'parts keys': [], 'parts refund': ['game'],
|
|
823
|
+
'shop connect': ['url', 'managed', 'live', 'manual', 'renew', 'from-clipboard'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note', 'manual-transaction'], 'shop statements': ['url', 'period', 'send', 'cursor'],
|
|
823
824
|
'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
|
|
824
825
|
'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
|
|
825
826
|
// With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
|
|
@@ -877,7 +878,7 @@ async function main() {
|
|
|
877
878
|
}
|
|
878
879
|
if (cmd === 'perf' && sub === 'compare') {
|
|
879
880
|
if (!positional[2] || !positional[3]) return { ok: false, command: 'perf compare', why: 'usage: homie-studio perf compare <before dir> <after dir> [--goal <metric>] [--min 3]' };
|
|
880
|
-
return perfCompare(positional[2], positional[3], { goal: String(flags.get('goal')
|
|
881
|
+
return perfCompare(positional[2], positional[3], { goal: flags.has('goal') ? String(flags.get('goal')) : null, min: flags.has('min') ? Number(flags.get('min')) / 100 : 0.03, guards: flags.get('guards') ? String(flags.get('guards')).split(',').map((x) => x.trim()).filter(Boolean) : null, also: flags.get('also') ? String(flags.get('also')).split(',').map((x) => x.trim()).filter(Boolean) : [] });
|
|
881
882
|
}
|
|
882
883
|
// The starter library and the optimiser work anywhere; the rest of style and assets inside a studio (lib/art-cli.mjs).
|
|
883
884
|
if (cmd === 'assets' && ['find', 'optimise', 'optimize'].includes(sub)) return assetsCommand(null, sub, positional, flags, { log });
|
|
@@ -1057,7 +1058,7 @@ async function main() {
|
|
|
1057
1058
|
if (!sub) return shopStatus(root, { url });
|
|
1058
1059
|
if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), currency: flags.get('currency') ?? 'usd', price: flags.get('price') ?? 500, managed: flags.has('managed') });
|
|
1059
1060
|
if (sub === 'check') return shopCheck(root);
|
|
1060
|
-
if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
|
|
1061
|
+
if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), fromClipboard: flags.has('from-clipboard'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
|
|
1061
1062
|
if (sub === 'disconnect') return shopDisconnect(root);
|
|
1062
1063
|
if (sub === 'catalog') {
|
|
1063
1064
|
const from = flags.get('have');
|
|
@@ -1072,7 +1073,7 @@ async function main() {
|
|
|
1072
1073
|
return catalogPlan(root, { have, mode: mode ?? null });
|
|
1073
1074
|
}
|
|
1074
1075
|
if (sub === 'orders') return shopOrders(root, { url });
|
|
1075
|
-
if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
|
|
1076
|
+
if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note'), manualTransaction: flags.get('manual-transaction') });
|
|
1076
1077
|
if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), cursor: flags.get('cursor') ?? '', send: flags.has('send') });
|
|
1077
1078
|
return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, catalog, orders, refund, statements)` };
|
|
1078
1079
|
}
|
|
@@ -1326,7 +1327,7 @@ try {
|
|
|
1326
1327
|
if (result && result.command !== 'help') print(result);
|
|
1327
1328
|
if (result?.ok === false) process.exitCode = 1;
|
|
1328
1329
|
// Chrome's pipes can outlive browser.close(); a finished check must not hang its caller.
|
|
1329
|
-
if (['check', 'port check', 'look', 'perf', 'shoot'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1330
|
+
if (['check', 'port check', 'look', 'perf', 'shoot', 'lab check'].includes(result?.command)) process.exit(process.exitCode ?? 0);
|
|
1330
1331
|
} catch (error) {
|
|
1331
1332
|
await flushProgress().catch(() => {});
|
|
1332
1333
|
print({ ok: false, why: error instanceof Error ? error.message : String(error) });
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { paidReleases, uploadPaidParts } from './parts-upload.mjs';
|
|
1
2
|
/**
|
|
2
3
|
* `homie-studio deploy` — the studio's site on the studio's OWN Cloudflare
|
|
3
4
|
* account, through Cloudflare's own tool (Wrangler, pinned in the studio).
|
|
@@ -349,12 +350,25 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
349
350
|
step(`created D1 ${cf.d1}`);
|
|
350
351
|
}
|
|
351
352
|
|
|
353
|
+
// Paid objects use a separate private bucket, so even old Worker versions cannot serve them.
|
|
354
|
+
if (paidReleases(root).length && !storage) return refuse('Paid releases need private storage. Run homie-studio storage add before deploying.');
|
|
355
|
+
const purchaseBucket = storage && `${storage.slice(0, 52)}-purchases`;
|
|
356
|
+
if (paidReleases(root).length && !created.has(`r2:${purchaseBucket}`)) {
|
|
357
|
+
const listed = w(['r2', 'bucket', 'list']);
|
|
358
|
+
if (listed.code !== 0) return refuse('Could not inspect private purchase storage', listed.out);
|
|
359
|
+
const buckets = parseJson(listed.out);
|
|
360
|
+
if (!Array.isArray(buckets)) return refuse('Could not read the private storage list', listed.out);
|
|
361
|
+
if (buckets.some((bucket) => bucket.name === purchaseBucket)) return refuse('The private purchase bucket already exists and is not owned by this studio');
|
|
362
|
+
const made = w(['r2', 'bucket', 'create', purchaseBucket]);
|
|
363
|
+
if (made.code !== 0) return refuse('Could not create private purchase storage', made.out);
|
|
364
|
+
remember(`r2:${purchaseBucket}`);
|
|
365
|
+
}
|
|
352
366
|
// Storage (R2) is bound only when `storage add` made the bucket. Deploy itself never creates or lists R2, so a
|
|
353
367
|
// free account with no payment method deploys the whole studio.
|
|
354
368
|
const r2 = storage;
|
|
355
369
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
356
370
|
|
|
357
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
371
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
358
372
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
359
373
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
360
374
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -362,7 +376,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
362
376
|
step('D1 migrations applied');
|
|
363
377
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
364
378
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
365
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
379
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
366
380
|
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
367
381
|
}
|
|
368
382
|
|
|
@@ -377,6 +391,11 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
377
391
|
dep = w(['deploy', ...repoVar(root)]);
|
|
378
392
|
}
|
|
379
393
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${dep.out.trim().split('\n').slice(-6).join(' ')}`, dep.out);
|
|
394
|
+
if (paidReleases(root).length) {
|
|
395
|
+
const bin = wranglerBin(root);
|
|
396
|
+
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, ...projectsCloudflareEnv(root), CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
397
|
+
await uploadPaidParts(root, { bucket: purchaseBucket, log, put: (key, file) => wranglerRun(bin, ['r2', 'object', 'put', key, '--file', file, '--remote'], uploadOptions), hash: (key) => hashRemote(bin, key, uploadOptions) });
|
|
398
|
+
}
|
|
380
399
|
remember(`worker:${cf.worker}`);
|
|
381
400
|
// The workers.dev address names the Cloudflare account (often after its owner): it stays on this computer, in
|
|
382
401
|
// .studio/local.json (git-ignored), and never in the committed studio.json. A custom domain stays in studio.json.
|
|
@@ -394,6 +413,12 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
394
413
|
|
|
395
414
|
// The homie.rocks directory claim: the live site claims itself the first time its manifest is read (0.10.0), so
|
|
396
415
|
// reading it once now is all it takes; nothing is stored by hand.
|
|
416
|
+
if (paidReleases(root).length) {
|
|
417
|
+
const { withKey } = await import('./office.mjs');
|
|
418
|
+
const synced = await withKey(root, url, (call) => call('/_studio/api/shop/sync-offers', {}));
|
|
419
|
+
if (!synced.ok) return refuse('Site deployed, but open part Checkouts could not be closed. Retry deploy: ' + (synced.message ?? synced.why ?? 'no answer'));
|
|
420
|
+
step(`closed ${synced.expired} obsolete part Checkouts`);
|
|
421
|
+
}
|
|
397
422
|
const directory = homie || studio.homie?.directory || 'https://homie.rocks';
|
|
398
423
|
recordDeploy(root, builtNow);
|
|
399
424
|
let claim = null;
|
|
@@ -655,7 +680,7 @@ export async function storageAdd(root, { log = () => {} } = {}) {
|
|
|
655
680
|
writeStudio(root, next);
|
|
656
681
|
if (next.cloudflare.d1Id) {
|
|
657
682
|
// The studio's own routes stay through this rewrite too (lib/routes.mjs).
|
|
658
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
|
|
683
|
+
writeFileSync(configPath(root), wranglerConfig({ partsRateLimit: readConfig(root)?.ratelimits?.find((binding) => binding.name === 'PURCHASE_RATE_LIMITER'), paidParts: paidReleases(root).length > 0 || readConfig(root)?.alias?.['@homie-rocks/studio/worker'] === '@homie-rocks/studio/worker/selling', worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: next.cloudflare.d1Id, r2: bucket, layout: layoutOf(root), routes: keptRoutes(root, studio) }));
|
|
659
684
|
}
|
|
660
685
|
log(`created R2 ${bucket}`);
|
|
661
686
|
return { ok: true, command: 'storage add', bucket, account: accountId, cost: R2_COST, next: ['npx --no-install homie-studio media move --dry-run (which songs and videos go to R2: over 1 MiB, or left out of git)', 'npm run deploy (binds the bucket as MEDIA, moves them, checks each by SHA-256, and serves them from R2 at the same addresses; the files stay in this folder)'] };
|
package/lib/dev.mjs
CHANGED
|
@@ -81,7 +81,7 @@ export function runsFromStudio(pid, root) {
|
|
|
81
81
|
export function processOfStudio(pid, root, kind) {
|
|
82
82
|
if (!Number.isInteger(pid) || pid <= 1 || pid === process.pid || !isAlive(pid)) return false;
|
|
83
83
|
const cmd = commandOf(pid);
|
|
84
|
-
if (cmd === null) return process.platform === 'win32'; // no ps: the registration file is all there is
|
|
84
|
+
if (cmd === null) return process.platform === 'win32' || runsFromStudio(pid, root); // no ps: the registration file is all there is
|
|
85
85
|
if (kind === 'dev' ? /homie-studio/.test(cmd) : /wrangler|workerd/.test(cmd)) return true;
|
|
86
86
|
return runsFromStudio(pid, root);
|
|
87
87
|
}
|
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
|
+
}
|