@homie-rocks/studio 0.37.0 → 0.37.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/bin/homie-studio.mjs +10 -3
  3. package/lib/cloudflare.mjs +40 -6
  4. package/lib/dev.mjs +1 -1
  5. package/lib/doctor.mjs +7 -7
  6. package/lib/domain.mjs +43 -0
  7. package/lib/jobs.mjs +17 -7
  8. package/lib/mcp-tools.mjs +49 -18
  9. package/lib/mcp.mjs +6 -11
  10. package/lib/parts-build.mjs +23 -4
  11. package/lib/parts-cli.mjs +54 -3
  12. package/lib/parts-purchase.mjs +174 -0
  13. package/lib/parts-store.mjs +50 -19
  14. package/lib/parts-tools.mjs +15 -12
  15. package/lib/parts-upload.mjs +43 -0
  16. package/lib/parts.mjs +49 -5
  17. package/lib/prepare.mjs +47 -0
  18. package/lib/purchase-links.mjs +42 -0
  19. package/lib/purchase-wallet.mjs +114 -0
  20. package/lib/scaffold.mjs +35 -5
  21. package/lib/shoot.mjs +1 -1
  22. package/lib/shop-links.mjs +15 -6
  23. package/lib/shop.mjs +91 -21
  24. package/lib/stripe-connect.mjs +2 -2
  25. package/lib/template-history.json +82 -0
  26. package/package.json +12 -2
  27. package/parts/COMMERCIAL-LICENCE.md +21 -0
  28. package/parts/PARTS.md +238 -10
  29. package/worker/extensions.mjs +13 -0
  30. package/worker/index.mjs +3 -2
  31. package/worker/machine-payments.mjs +72 -0
  32. package/worker/office.mjs +4 -1
  33. package/worker/parts-protected.mjs +111 -0
  34. package/worker/parts-purchase-catalogue.mjs +34 -0
  35. package/worker/parts-resource.mjs +32 -0
  36. package/worker/parts-sale.mjs +59 -0
  37. package/worker/parts.mjs +24 -11
  38. package/worker/payment-capabilities.mjs +70 -0
  39. package/worker/payment-dry-exchange.mjs +30 -0
  40. package/worker/payment-manual-refund.mjs +40 -0
  41. package/worker/payment-policy.mjs +40 -0
  42. package/worker/payment-recovery.mjs +163 -0
  43. package/worker/payment-test-exchange.mjs +19 -0
  44. package/worker/purchase-core.mjs +140 -0
  45. package/worker/purchase-crypto.mjs +17 -0
  46. package/worker/purchase-delivery.mjs +108 -0
  47. package/worker/purchase-discovery.mjs +26 -0
  48. package/worker/purchase-hosted-readiness.mjs +16 -0
  49. package/worker/purchase-keys.mjs +20 -0
  50. package/worker/purchase-links.mjs +9 -0
  51. package/worker/purchase-machine.mjs +279 -0
  52. package/worker/purchase-mcp.mjs +42 -0
  53. package/worker/purchase-office-routes.mjs +17 -0
  54. package/worker/purchase-office-script.mjs +38 -0
  55. package/worker/purchase-office.mjs +35 -0
  56. package/worker/purchase-owner.mjs +243 -0
  57. package/worker/purchase-pricing.mjs +8 -0
  58. package/worker/purchase-problem.mjs +4 -0
  59. package/worker/purchase-reconciliation.mjs +582 -0
  60. package/worker/purchase-response.mjs +82 -0
  61. package/worker/purchase-routes.mjs +611 -0
  62. package/worker/purchase-schema.mjs +50 -0
  63. package/worker/purchase-store.mjs +148 -0
  64. package/worker/purchases.mjs +6 -0
  65. package/worker/resource-kinds.mjs +20 -0
  66. package/worker/selling/index.mjs +8 -0
  67. package/worker/selling/runtime.mjs +42 -0
  68. package/worker/settlement-journal.mjs +138 -0
  69. package/worker/shop-page.mjs +7 -4
  70. package/worker/shop-refunds.mjs +22 -0
  71. package/worker/shop-rules.mjs +1 -1
  72. package/worker/shop.mjs +18 -18
  73. package/worker/stripe.mjs +36 -3
  74. package/worker/version.mjs +1 -1
@@ -3,10 +3,10 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
4
4
  import { defaultTaxCode } from '../worker/shop-rules.mjs';
5
5
  import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/shop-links.mjs';
6
- import { HOOK_EVENTS } from './shop.mjs';
6
+ import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt, saveReceipt }) {
9
+ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt }) {
10
10
  const list = async (path, params = {}) => {
11
11
  const all = []; let cursor;
12
12
  do {
@@ -62,26 +62,35 @@ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt
62
62
  keepLinks.add(link.id);
63
63
  config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
64
64
  }
65
+ const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
66
+ const purchaseSecrets = {};
67
+ if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
68
+ const { generateKeyPair, exportJWK } = await import('jose');
69
+ const pair = await generateKeyPair('Ed25519', { extractable: true });
70
+ purchaseSecrets.PURCHASE_SIGNING_KEYS = JSON.stringify([await exportJWK(pair.privateKey)]);
71
+ }
72
+ const events = purchases?.config.offers.length ? HOOK_EVENTS : SHOP_HOOK_EVENTS;
65
73
  const endpoints = await list('/v1/webhook_endpoints');
66
74
  const url = `${site}/api/shop/hook`;
67
- const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events?.includes(event)));
75
+ const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && events.every((event) => e.enabled_events?.includes(event)));
68
76
  let endpoint = current, secret;
69
77
  if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
70
78
  const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
71
79
  saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
72
- endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...HOOK_EVENTS], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
80
+ endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...events], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
73
81
  if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
74
82
  secret = endpoint.secret;
75
83
  }
76
- const fingerprint = hash(config);
84
+ const fingerprint = hash({config, purchases:purchases?.config});
77
85
  const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
78
86
  if (!unchanged) {
79
- const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
87
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
80
88
  if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
81
89
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
82
90
  if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
83
91
  saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
84
92
  }
93
+ await purchases?.retire();
85
94
  // Retire only after the replacement configuration and signing secret are installed.
86
95
  for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
87
96
  for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
package/lib/shop.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { paidReleases } from './parts-upload.mjs';
1
2
  /**
2
3
  * `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
3
4
  * rules, shop/SHOP.md the guide, shop/SELLING.md the owner's plain words).
@@ -25,9 +26,10 @@
25
26
  *
26
27
  * The proof of ownership is the studio's own Cloudflare login, as for the office.
27
28
  */
29
+ import { execFileSync } from 'node:child_process';
28
30
  import { createServer } from 'node:http';
29
- import { randomBytes } from 'node:crypto';
30
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
31
+ import { randomBytes, createHash } from 'node:crypto';
32
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
31
33
  import { dirname, join } from 'node:path';
32
34
  import { fileURLToPath } from 'node:url';
33
35
  import { askedFor, withKey } from './office.mjs';
@@ -36,7 +38,7 @@ import { listExperiences as listGames, readStudio, siteUrl } from './studio.mjs'
36
38
  import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
37
39
  import {
38
40
  KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
39
- stripeCall, updateWebhookEndpoint,
41
+ stripeCall, updateWebhookEndpoint, redactStripe,
40
42
  } from '../worker/stripe.mjs';
41
43
 
42
44
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -44,10 +46,29 @@ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
44
46
  /** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
45
47
  export const HOOK_EVENTS = Object.freeze([
46
48
  'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
47
- 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
49
+ 'payment_intent.succeeded', 'invoice.paid', 'invoice_payment.paid', 'invoice.payment_failed', 'customer.subscription.updated', 'customer.subscription.deleted', 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
48
50
  ]);
49
51
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
50
52
  export const KEY_PERMISSIONS = Object.freeze([
53
+ ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
54
+ ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
55
+ ['PaymentIntents', 'Write', 'verify, create and cancel machine payments as well as read purchase receipts'],
56
+ ['Business Profiles', 'Read', 'discover the Stripe profile used by shared payment tokens'],
57
+ ['Payment method configurations', 'Read', 'check whether Stripe has enabled crypto payments'],
58
+ ['Crypto Deposit Addresses', 'Read', 'read this account’s existing stablecoin deposit addresses'],
59
+ ['Subscriptions', 'Write', 'verify paid periods and cancel part subscriptions when the studio retires a part or its refund policy requires cancellation'],
60
+ ['Invoices', 'Read', 'verify part renewal payments'],
61
+ ['Customer portal', 'Write', 'let part buyers manage or cancel their subscription'],
62
+ ['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
63
+ ['Webhook Endpoints', 'Write', 'only so this page makes the webhook for you; you can set it back to None afterwards'],
64
+ ]);
65
+
66
+ export const SHOP_HOOK_EVENTS = Object.freeze([
67
+ 'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
68
+ 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
69
+ ]);
70
+ /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
71
+ export const SHOP_KEY_PERMISSIONS = Object.freeze([
51
72
  ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
52
73
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
53
74
  ['PaymentIntents', 'Read', 'which payment an order was'],
@@ -110,19 +131,19 @@ export async function shopStatus(root, { url } = {}) {
110
131
  const { stripeConnectionInfo } = await import('./stripe-connect.mjs');
111
132
  const connection = r.mode === 'test' ? stripeConnectionInfo(root) : null;
112
133
  const missing = [...(r.missing ?? []), ...(connection?.expired ? [{ id: 'stripe-expired', words: 'Stripe test credentials have expired. Run homie-studio shop connect --renew and approve Stripe again.' }] : [])];
113
- return { ok: true, command: 'shop', ready: r.ready && !connection?.expired, mode: r.mode, missing, connection, totals: r.totals, stripe: r.stripe, hook: r.hook, till: r.shop?.till ?? null, items: r.shop?.items ?? [], local: { ok: local.ok, absent: Boolean(local.absent), errors: local.errors } };
134
+ return { ok: true, command: 'shop', ready: r.ready && !connection?.expired, mode: r.mode, missing, connection, purchases: r.purchases, totals: r.totals, stripe: r.stripe, hook: r.hook, till: r.shop?.till ?? null, items: r.shop?.items ?? [], local: { ok: local.ok, absent: Boolean(local.absent), errors: local.errors } };
114
135
  }
115
136
 
116
137
  export async function shopOrders(root, { url } = {}) {
117
138
  const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
118
139
  if (!r.ok) return { ok: false, command: 'shop orders', why: r.message ?? r.why ?? 'the studio did not answer' };
119
- return { ok: true, command: 'shop orders', orders: (r.orders ?? []).map((o) => ({ id: o.id, at: new Date(o.createdAt).toISOString(), item: o.item, name: o.name, shown: o.shown, status: o.status, mode: o.mode, player: o.player?.name ?? null, via: o.via, stripe: o.stripe })) };
140
+ return { ok: true, command: 'shop orders', orders: (r.orders ?? []).map((o) => ({ id: o.id, at: new Date(o.createdAt).toISOString(), item: o.item, name: o.name, shown: o.shown, status: o.status, mode: o.mode, player: o.player?.name ?? null, via: o.via, stripe: o.stripe, manualRefund: o.manualRefund ?? null })) };
120
141
  }
121
142
 
122
143
  /** The AI proposes a refund: an ask the owner confirms with one tap (an office key can never refund by itself). */
123
- export async function shopRefund(root, order, { url, reason, note } = {}) {
144
+ export async function shopRefund(root, order, { url, reason, note, manualTransaction } = {}) {
124
145
  if (!/^ord_[A-Za-z0-9]{20}$/.test(String(order ?? ''))) return { ok: false, command: 'shop refund', why: 'name the order: homie-studio shop refund ord_… (homie-studio shop orders lists them)' };
125
- const r = await withKey(root, url, (call) => call('/_studio/api/shop/refund', { order, ...(reason ? { reason } : {}), ...(note ? { note } : {}) }));
146
+ const r = await withKey(root, url, (call) => call('/_studio/api/shop/refund', { order, ...(manualTransaction ? {manualTransaction} : {}), ...(reason ? { reason } : {}), ...(note ? { note } : {}) }));
126
147
  return askedFor(root, url, r, 'shop refund');
127
148
  }
128
149
 
@@ -157,10 +178,10 @@ export const hookUrlOf = (site) => `${String(site).replace(/\/+$/, '')}/api/shop
157
178
  const HOOK_MARK = 'shop-v1';
158
179
 
159
180
  /** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, one field, one button. */
160
- export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
181
+ export function connectPage({ nonce, site, studio, till = 'stripe', test = true, paidParts = false }) {
161
182
  const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
162
- const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
163
- const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
183
+ const perms = (paidParts ? KEY_PERMISSIONS : SHOP_KEY_PERMISSIONS).map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
184
+ const events = (paidParts ? HOOK_EVENTS : SHOP_HOOK_EVENTS).map((e) => `<code>${esc(e)}</code>`).join(', ');
164
185
  return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="referrer" content="no-referrer"><title>Connect your shop to Stripe</title>
165
186
  <style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}details{margin-top:1rem}summary{cursor:pointer}</style>
166
187
  <h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
@@ -172,7 +193,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
172
193
  <li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
173
194
  <li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
174
195
  <h2>2. The webhook: this page makes it</h2>
175
- <p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${HOOK_EVENTS.length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
196
+ <p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${(paidParts ? HOOK_EVENTS : SHOP_HOOK_EVENTS).length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
176
197
  <form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
177
198
  <h2>3. Paste the key here</h2>
178
199
  <label for="key">Stripe key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
@@ -186,6 +207,15 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
186
207
  <label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Set automaticTax: true in shop.json to enable Stripe Tax: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
187
208
  <label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).${test ? ' In test mode this page tries one test checkout with Managed Payments (expired at once) and says whether Stripe takes it.' : ''}</span></label>
188
209
  </fieldset>
210
+ ${paidParts ? `<h2>5. Paid resource checkout and optional Base payments</h2>
211
+ <p>For hosted Checkout, create a Turnstile widget for this studio hostname and enter both keys. Blank fields preserve existing secrets.</p>
212
+ <label>TURNSTILE_SITE_KEY<input name="TURNSTILE_SITE_KEY" autocomplete="off"></label>
213
+ <label>TURNSTILE_SECRET<input name="TURNSTILE_SECRET" type="password" autocomplete="off"></label>
214
+ <p>Live Base payments also require a Coinbase CDP API key.</p>
215
+ <label>CDP_API_KEY_ID<input name="CDP_API_KEY_ID" type="password" autocomplete="off"></label>
216
+ <label>CDP_API_KEY_SECRET<input name="CDP_API_KEY_SECRET" type="password" autocomplete="off"></label>
217
+ <label><input type="checkbox" name="base" value="yes"> Enable Base stablecoin payments. I can refund the original payer directly on Base if Stripe cannot record a settled transfer; I accept this refund responsibility. Live mode requires both CDP keys here.</label>` : ''}
218
+ <p>Tempo is unavailable until complete payment tests pass in both modes.</p>
189
219
  <button>Save to my Worker</button></form>
190
220
  <p><small>This page works once and closes in ten minutes. You can change the seller later in shop.json ("till"). This is not legal or tax advice: an accountant can tell you where you must register.</small></p>`;
191
221
  }
@@ -198,7 +228,7 @@ const said = (status, text) => ({ status, text });
198
228
  * for the same address are turned off (not deleted) once the new secret is saved. { ok, id, secret, older } or
199
229
  * { ok: false, page } (what the page tells the owner).
200
230
  */
201
- async function makeWebhook(env, site, { fetcher }) {
231
+ async function makeWebhook(env, site, { fetcher, paidParts = false }) {
202
232
  const url = hookUrlOf(site);
203
233
  let older = [];
204
234
  try {
@@ -211,7 +241,7 @@ async function makeWebhook(env, site, { fetcher }) {
211
241
  let made = null;
212
242
  try {
213
243
  made = await createWebhookEndpoint(env, {
214
- url, enabled_events: [...HOOK_EVENTS], api_version: STRIPE_VERSION,
244
+ url, enabled_events: [...(paidParts ? HOOK_EVENTS : SHOP_HOOK_EVENTS)], api_version: STRIPE_VERSION,
215
245
  description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
216
246
  }, { fetcher });
217
247
  } catch (error) {
@@ -248,7 +278,7 @@ async function probeManaged(env, shop, { fetcher }) {
248
278
  * read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
249
279
  * which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
250
280
  */
251
- export async function shopConnectManual(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
281
+ export async function shopConnectManual(root, { fromClipboard = false, clipboard = null, managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
252
282
  const studio = readStudio(root);
253
283
  const site = siteUrl(root);
254
284
  if (!site) return { ok: false, command: 'shop connect', needs: 'deploy', why: 'the studio has no live address yet, and Stripe needs one to send payments to (the webhook): run npm run deploy first, then this' };
@@ -257,7 +287,8 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
257
287
  const shopNow = readShop(root);
258
288
  const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
259
289
  const nonce = randomBytes(16).toString('hex');
260
- const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
290
+ const paidParts = paidReleases(root).length > 0;
291
+ const page = connectPage({ paidParts, nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
261
292
  return await new Promise((done) => {
262
293
  let finished = false;
263
294
  let busy = false;
@@ -278,6 +309,8 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
278
309
  // Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
279
310
  if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE Stripe key (rk_live_… or sk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_… or sk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --manual --live).')); return; }
280
311
  if (hook && !WEBHOOK_SECRET_SHAPE.test(hook)) { say(said(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again, or leave it empty and this page makes the webhook.')); return; }
312
+ if (Boolean(form.get('TURNSTILE_SITE_KEY')) !== Boolean(form.get('TURNSTILE_SECRET')) || Boolean(form.get('CDP_API_KEY_ID')) !== Boolean(form.get('CDP_API_KEY_SECRET'))) { say(said(400, 'Supply both keys in each pair.')); return; }
313
+ if (live && form.get('base') === 'yes' && !form.get('CDP_API_KEY_SECRET')) { say(said(400, 'Enabling live Base requires both CDP keys.')); return; }
281
314
  busy = true;
282
315
  try {
283
316
  const sk = { STRIPE_KEY: key, STRIPE_API_BASE: process.env.STRIPE_API_BASE };
@@ -292,16 +325,41 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
292
325
  }
293
326
  let webhook = { made: false };
294
327
  if (!hook) {
295
- const m = await makeWebhook(sk, site, { fetcher });
328
+ const m = await makeWebhook(sk, site, { fetcher, paidParts });
296
329
  if (!m.ok) { log(m.page.text); say(m.page); return; }
297
330
  hook = m.secret;
298
331
  webhook = { made: true, id: m.id, older: m.older };
299
332
  }
333
+ let recurringReady = false;
334
+ try {
335
+ if (paidParts) {
336
+ await stripeCall(sk, 'GET', '/v1/subscriptions', { limit: 1 }, { fetcher });
337
+ await stripeCall(sk, 'GET', '/v1/invoices', { limit: 1 }, { fetcher });
338
+ await stripeCall(sk, 'GET', '/v1/invoice_payments', { limit: 1 }, { fetcher });
339
+ const endpoints = webhook.made ? null : await listWebhookEndpoints(sk, { fetcher });
340
+ recurringReady = webhook.made || endpoints?.data?.some((e) => e.url === hookUrlOf(site) && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events.includes(event) || e.enabled_events.includes('*')));
341
+ }
342
+ } catch { recurringReady = false; }
343
+ const machine = paidParts ? await (await import('../worker/payment-capabilities.mjs')).probeMachinePayments(sk, { fetcher }) : { machine: {}, configuration: {}, reasons: {} };
344
+ const machineNote = ` Machine payments: ${Object.entries(machine.machine).map(([name, enabled]) => `${name}: ${enabled ? 'permission check passed' : machine.reasons[name] ?? 'unavailable'}`).join('; ')}.`;
300
345
  finished = true;
301
346
  const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
302
347
  const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
303
348
  hook = '';
304
- const okay = a.code === 0 && b.code === 0;
349
+ const readiness = { ...machine, key: createHash('sha256').update(key).digest('hex'), mode: modeOf(key), recurring: recurringReady };
350
+ const c = !paidParts ? b : b.code === 0 ? w(['secret', 'put', 'PURCHASE_PAYMENT_CAPABILITIES'], { input: JSON.stringify(readiness) }) : { code: 1 };
351
+ const settingsPath = join(root, '.homie', 'purchase-settings.json');
352
+ const prior = existsSync(settingsPath) ? JSON.parse(readFileSync(settingsPath, 'utf8')) : {};
353
+ const next = { ...prior, [modeOf(key)]: { ...prior[modeOf(key)], ...machine.configuration } };
354
+ delete next[modeOf(key)].tempo;
355
+ if (form.get('base') === 'yes' && machine.machine.base) next[modeOf(key)].manualRefunds = true;
356
+ else { delete next[modeOf(key)].base; next[modeOf(key)].manualRefunds = false; }
357
+ const extras = ['TURNSTILE_SITE_KEY','TURNSTILE_SECRET','CDP_API_KEY_ID','CDP_API_KEY_SECRET'];
358
+ let settingsOkay = c.code === 0;
359
+ for (const setting of extras) if (paidParts && settingsOkay && form.get(setting)) settingsOkay = w(['secret','put',setting], { input: form.get(setting).trim() }).code === 0;
360
+ const d = !paidParts ? c : settingsOkay ? w(['secret', 'put', 'PURCHASE_MACHINE_PAYMENTS'], { input: JSON.stringify(next) }) : { code: 1 };
361
+ if (paidParts && d.code === 0) { mkdirSync(dirname(settingsPath), { recursive: true }); writeFileSync(settingsPath, JSON.stringify(next, null, 2) + '\n'); }
362
+ const okay = a.code === 0 && b.code === 0 && c.code === 0 && d.code === 0;
305
363
  // The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
306
364
  // not deleted: the owner can turn one back on in Stripe).
307
365
  let disabled = 0;
@@ -322,12 +380,13 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
322
380
  } catch { tillNote = ''; }
323
381
  }
324
382
  const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
325
- say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check?.ok ? ` ${check.words}` : ''}${warnings.length ? ` ${warnings.join(' ')}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
383
+ say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check?.ok ? ` ${check.words}` : ''}${warnings.length ? ` ${warnings.join(' ')}` : ''}${narrow}${machineNote} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
326
384
  server.close();
327
385
  done(okay
328
386
  ? {
329
387
  ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
330
388
  webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
389
+ machinePayments: { methods: machine.machine, reasons: machine.reasons },
331
390
  ...(check ? { managedPayments: check } : {}),
332
391
  ...(warnings.length ? { warnings } : {}),
333
392
  message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
@@ -339,9 +398,20 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
339
398
  }
340
399
  res.writeHead(404); res.end();
341
400
  });
342
- server.listen(port, '127.0.0.1', () => {
401
+ server.listen(port, '127.0.0.1', async () => {
343
402
  const link = `http://127.0.0.1:${server.address().port}/${nonce}`;
344
- log(`Open this page on this computer; the owner pastes the key there (never in a chat): ${link}`);
403
+ if (fromClipboard) {
404
+ try {
405
+ // The owner's agent clicks Copy in Stripe. The key never enters a tool response or argv.
406
+ let key = clipboard ? await clipboard() : process.platform === 'darwin' ? execFileSync('pbpaste', {encoding:'utf8'}).trim() : process.platform === 'win32' ? execFileSync('powershell',['-NoProfile','-Command','Get-Clipboard'],{encoding:'utf8'}).trim() : execFileSync('wl-paste',['--no-newline'],{encoding:'utf8'}).trim();
407
+ if (!KEY_SHAPE.test(key)) throw new Error('Clipboard does not contain a Stripe credential');
408
+ const result = await fetch(`http://127.0.0.1:${server.address().port}/key`, {method:'POST',body:new URLSearchParams({key,n:nonce,till})});
409
+ key = '';
410
+ if (!result.ok) { finished=true;server.close();done({ok:false,command:'shop connect',why:redactStripe(await result.text()).slice(0,800) || 'Credential setup failed. Check the provider permissions and payment mode; no credential was printed.'}); }
411
+ } catch { finished=true;server.close();done({ok:false,command:'shop connect',why:'The agent must copy the approved Stripe key in the browser first. macOS, Windows or Wayland clipboard tooling is required; no credential was printed.'}); } finally {
412
+ if (!clipboard) { try { if(process.platform==='darwin') execFileSync('pbcopy',[],{input:''}); else if(process.platform==='win32') execFileSync('powershell',['-NoProfile','-Command','Set-Clipboard -Value ""']); else execFileSync('wl-copy',['--clear']); } catch {} }
413
+ }
414
+ } else log(`Open this page on this computer; the owner pastes the key there (never in a chat): ${link}`);
345
415
  });
346
416
  setTimeout(() => { if (!finished) { finished = true; server.close(); done({ ok: false, command: 'shop connect', why: 'nothing was saved in ten minutes; run it again when ready' }); } }, wait).unref?.();
347
417
  });
@@ -72,7 +72,7 @@ export function completionArgs(step) {
72
72
  }
73
73
 
74
74
  export async function shopConnect(root, options = {}) {
75
- if (options.manual) {
75
+ if (options.manual || options.fromClipboard) {
76
76
  const result = await shopConnectManual(root, options);
77
77
  if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
78
78
  return result;
@@ -133,7 +133,7 @@ export async function shopConnect(root, options = {}) {
133
133
  const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
134
134
  const receiptFile = join(dir, `${mode}.json`);
135
135
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
136
- const result = await syncPaymentLinks({ api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
136
+ const result = await syncPaymentLinks({ root, api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
137
137
  saveReceipt: (record) => {
138
138
  writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
139
139
  renameSync(`${receiptFile}.tmp`, receiptFile);
@@ -2030,6 +2030,88 @@
2030
2030
  }
2031
2031
  }
2032
2032
  }
2033
+ },
2034
+ "0.37.1": {
2035
+ "files": {
2036
+ "CLAUDE.md": "ddf2b2a0376202f6",
2037
+ "README.md": "684b3cca8001673c",
2038
+ "HANDOFF.md": "29337e2ace95b2e7",
2039
+ "games/README.md": "5ad32fe80c61f117",
2040
+ "music/README.md": "a3dc042dadd39df8",
2041
+ "videos/README.md": "16babc3437d5545b",
2042
+ "posts/README.md": "37841aae476bd540",
2043
+ "site/README.md": "732894273a32dc3f",
2044
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2045
+ },
2046
+ "sections": {
2047
+ "AGENTS.md": {
2048
+ "(intro)": "214aa46eb3a895d5",
2049
+ "## Layout": "c18362ed28975d81",
2050
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2051
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2052
+ "## The site": "0cb8dd9a6f8a0096",
2053
+ "## Making games": "2da7fa74c8884834",
2054
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2055
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2056
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2057
+ "## Servers and AI seats": "98d08705087a33ec",
2058
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2059
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2060
+ "## Rules": "2e6b29afb85dcf7e",
2061
+ "## Telling Homie": "80b46fdd9e578db8",
2062
+ "## Beta": "9d02638207a26e73"
2063
+ }
2064
+ },
2065
+ "named": {
2066
+ "Homie Arcade": {
2067
+ "sections": {
2068
+ "AGENTS.md": {
2069
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2070
+ }
2071
+ }
2072
+ }
2073
+ }
2074
+ },
2075
+ "0.37.2": {
2076
+ "files": {
2077
+ "CLAUDE.md": "ddf2b2a0376202f6",
2078
+ "README.md": "684b3cca8001673c",
2079
+ "HANDOFF.md": "29337e2ace95b2e7",
2080
+ "games/README.md": "5ad32fe80c61f117",
2081
+ "music/README.md": "a3dc042dadd39df8",
2082
+ "videos/README.md": "16babc3437d5545b",
2083
+ "posts/README.md": "37841aae476bd540",
2084
+ "site/README.md": "732894273a32dc3f",
2085
+ "site/src/worker.mjs": "fc90f3609cad18ba"
2086
+ },
2087
+ "sections": {
2088
+ "AGENTS.md": {
2089
+ "(intro)": "214aa46eb3a895d5",
2090
+ "## Layout": "c18362ed28975d81",
2091
+ "## Apps: one screen, roles and parts": "4db0abe4cdfac09f",
2092
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
2093
+ "## The site": "0cb8dd9a6f8a0096",
2094
+ "## Making games": "2da7fa74c8884834",
2095
+ "## The Game Codex and progress": "0738ef719cef3a4c",
2096
+ "## Art direction and models": "a48fff7bf0a5f2fc",
2097
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
2098
+ "## Servers and AI seats": "98d08705087a33ec",
2099
+ "## Selling things (the shop)": "9e020d69dd6a954e",
2100
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
2101
+ "## Rules": "2e6b29afb85dcf7e",
2102
+ "## Telling Homie": "80b46fdd9e578db8",
2103
+ "## Beta": "9d02638207a26e73"
2104
+ }
2105
+ },
2106
+ "named": {
2107
+ "Homie Arcade": {
2108
+ "sections": {
2109
+ "AGENTS.md": {
2110
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
2111
+ }
2112
+ }
2113
+ }
2114
+ }
2033
2115
  }
2034
2116
  }
2035
2117
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.37.0",
3
+ "version": "0.37.2",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -83,7 +83,8 @@
83
83
  "./apps": {
84
84
  "types": "./apps/apps.ts",
85
85
  "default": "./apps/apps.ts"
86
- }
86
+ },
87
+ "./worker/selling": "./worker/selling/index.mjs"
87
88
  },
88
89
  "files": [
89
90
  "bin/",
@@ -121,16 +122,25 @@
121
122
  "@babel/parser": "8.0.7",
122
123
  "@babel/traverse": "8.0.7",
123
124
  "@babel/types": "8.0.6",
125
+ "@coinbase/x402": "2.1.0",
124
126
  "@gltf-transform/core": "4.5.1",
125
127
  "@gltf-transform/extensions": "4.5.1",
126
128
  "@gltf-transform/functions": "4.5.1",
127
129
  "@jridgewell/trace-mapping": "0.3.31",
130
+ "@modelcontextprotocol/sdk": "1.32.1",
131
+ "@x402/core": "2.28.0",
132
+ "@x402/evm": "2.28.0",
128
133
  "esbuild": "0.28.1",
129
134
  "gltf-validator": "2.0.0-dev.3.10",
135
+ "jose": "6.2.12",
130
136
  "meshoptimizer": "1.3.0",
137
+ "mppx": "0.13.2",
131
138
  "puppeteer-core": "25.8.0",
132
139
  "sharp": "0.35.5",
140
+ "spdx-expression-parse": "5.0.0",
141
+ "stripe": "23.0.0",
133
142
  "three": "0.185.1",
143
+ "viem": "2.57.4",
134
144
  "typescript": "5.9.3"
135
145
  },
136
146
  "scripts": {
@@ -0,0 +1,21 @@
1
+ # Optional commercial licence starting text
2
+
3
+ This is a drafting aid, not a selected policy or a substitute for the seller's review.
4
+ Copy and adapt it into the release's licenseTerms file, use a LicenseRef identifier,
5
+ and hash that file by packing the release. Fill in a complete agreement before sharing.
6
+
7
+ The seller grants the purchasing studio the rights specified in the signed purchase
8
+ schedule for the identified part and release. That schedule's scope, licensed game,
9
+ seat count, source inclusion, commercial-use permission, transfer permission, update
10
+ coverage and existing-use rights after refund or expiry control over conflicting general
11
+ language in this agreement. A seat means one human contributor; authorised build systems
12
+ may process the part on that contributor's behalf. A game licence covers only the named
13
+ game; a studio licence covers the purchasing studio's team and games.
14
+
15
+ State the permitted incorporation and distribution in finished games, whether attribution
16
+ is required, any restrictions on redistribution of source or standalone assets, support
17
+ and delivery availability, and the exact refund procedure and terms. Choose applicable
18
+ law, warranties and liability provisions suitable for the seller's circumstances and
19
+ mandatory buyer rights. Do not leave these decisions to the assistant. If the structured
20
+ schedule cannot express the intended agreement, do not sell through this contract until
21
+ both the schema and enforcement have been extended and reviewed.