@homie-rocks/studio 0.33.1 → 0.35.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/lib/shop.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
7
7
  * writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
8
8
  * homie-studio shop check shop.json against the studio settings and provider requirements
9
- * homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
9
+ * homie-studio shop connect sync Payment Links and webhook with the approved Stripe CLI; no Worker API key
10
+ * homie-studio shop connect --manual [--managed] [--live] a fallback page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
10
11
  * pastes the studio's restricted Stripe key (the one thing only they can make);
11
12
  * with it this process makes the webhook (0.24.3), and the key and the
12
13
  * webhook's signing secret go straight to the Worker secrets STRIPE_KEY and
@@ -32,7 +33,7 @@ import { fileURLToPath } from 'node:url';
32
33
  import { askedFor, withKey } from './office.mjs';
33
34
  import { runner } from './cloudflare.mjs';
34
35
  import { listGames, readStudio, siteUrl } from './studio.mjs';
35
- import { POLICY_PRESETS, SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
36
+ import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
36
37
  import {
37
38
  KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
38
39
  stripeCall, updateWebhookEndpoint,
@@ -43,7 +44,7 @@ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
43
44
  /** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
44
45
  export const HOOK_EVENTS = Object.freeze([
45
46
  'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
46
- 'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
47
+ 'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
47
48
  ]);
48
49
  /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
49
50
  export const KEY_PERMISSIONS = Object.freeze([
@@ -77,10 +78,10 @@ export function shopForBuild(root, { log = () => {} } = {}) {
77
78
  export function shopCheck(root) {
78
79
  const r = readShop(root);
79
80
  if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
80
- return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.kind === 'tip' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
81
+ return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.price === 'choose' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
81
82
  }
82
83
 
83
- /** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
84
+ /** The Supporter pack the kit suggests first: a badge on the profile and in rooms; the studio can replace or edit it. */
84
85
  export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
85
86
  return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
86
87
  }
@@ -91,7 +92,6 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
91
92
  const studio = readStudio(root);
92
93
  const shop = {
93
94
  till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
94
- policy: { preset: 'protective', ...POLICY_PRESETS.protective },
95
95
  items: supporter ? [supporterItem({ price: Number(price) })] : [],
96
96
  };
97
97
  const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
@@ -99,7 +99,7 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
99
99
  writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
100
100
  const wrote = [SHOP_FILE];
101
101
  if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
102
- return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (the shop stays closed until the key is in)', 'homie-studio shop connect (the owner pastes a restricted key into a page on this computer)'] };
102
+ return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (paid sales wait for the Stripe connection)', 'homie-studio shop connect (approve Stripe in your browser; the result names any remaining step)'] };
103
103
  }
104
104
 
105
105
  /** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
@@ -107,7 +107,10 @@ export async function shopStatus(root, { url } = {}) {
107
107
  const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
108
108
  if (!r.ok) return { ok: false, command: 'shop', why: r.message ?? r.why ?? 'the studio did not answer' };
109
109
  const local = readShop(root);
110
- return { ok: true, command: 'shop', ready: r.ready, mode: r.mode, missing: r.missing, 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 } };
110
+ const { stripeConnectionInfo } = await import('./stripe-connect.mjs');
111
+ const connection = r.mode === 'test' ? stripeConnectionInfo(root) : null;
112
+ 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 } };
111
114
  }
112
115
 
113
116
  export async function shopOrders(root, { url } = {}) {
@@ -162,8 +165,9 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
162
165
  <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>
163
166
  <h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
164
167
  <p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to Stripe (one check) and to your studio's Worker as a secret, and nowhere else: not to the chat, not to a file, not to a log.</p>
165
- <p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
166
- <h2>1. In Stripe: one restricted key</h2>
168
+ <p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code> or <code>sk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
169
+ <h2>1. In Stripe: a key for this shop</h2>
170
+ <p>You can use a restricted (<code>rk_</code>) or full secret (<code>sk_</code>) key. To choose a restricted key with just the permissions this shop uses:</p>
167
171
  <ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>. If Stripe asks what the key is for, choose your own integration, <b>not</b> "Authorizing agent access" (Stripe holds an agent key's refunds for a second approval).</li>
168
172
  <li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
169
173
  <li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
@@ -171,7 +175,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
171
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>
172
176
  <form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
173
177
  <h2>3. Paste the key here</h2>
174
- <label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
178
+ <label for="key">Stripe key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
175
179
  <details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
176
180
  <ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
177
181
  <li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
@@ -179,7 +183,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
179
183
  <label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
180
184
  <h2>4. Who is the seller?</h2>
181
185
  <fieldset>
182
- <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). Stripe Tax is on: 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>
186
+ <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>
183
187
  <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>
184
188
  </fieldset>
185
189
  <button>Save to my Worker</button></form>
@@ -225,7 +229,7 @@ async function makeWebhook(env, site, { fetcher }) {
225
229
  * (there is no API to read it). Never in live mode.
226
230
  */
227
231
  async function probeManaged(env, shop, { fetcher }) {
228
- const item = shop?.items?.find((i) => i.kind !== 'tip') ?? null;
232
+ const item = shop?.items?.find((i) => i.price !== 'choose') ?? null;
229
233
  try {
230
234
  const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
231
235
  mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
@@ -240,11 +244,11 @@ async function probeManaged(env, shop, { fetcher }) {
240
244
  }
241
245
 
242
246
  /**
243
- * `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
247
+ * `homie-studio shop connect --manual`: the fallback page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
244
248
  * read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
245
249
  * which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
246
250
  */
247
- export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
251
+ export async function shopConnectManual(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
248
252
  const studio = readStudio(root);
249
253
  const site = siteUrl(root);
250
254
  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' };
@@ -270,10 +274,9 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
270
274
  const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
271
275
  if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
272
276
  if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
273
- if (/^sk_live_/.test(key)) { say(said(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the permissions on the page. Go back and make one.')); return; }
274
- if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(said(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.')); return; }
277
+ if (!KEY_SHAPE.test(key)) { say(said(400, 'That does not look like a Stripe key (rk_test_…, rk_live_…, sk_test_… or sk_live_…). Go back and try again.')); return; }
275
278
  // Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
276
- if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE restricted key (rk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
279
+ 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; }
277
280
  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; }
278
281
  busy = true;
279
282
  try {
@@ -343,7 +346,8 @@ export function shopDisconnect(root) {
343
346
  const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
344
347
  const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
345
348
  const b = w(['secret', 'delete', 'STRIPE_WEBHOOK_SECRET'], { input: 'y\n' });
346
- return a.code === 0 || b.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Stripe key and webhook secret are gone from the Worker: the shop is closed. Delete the restricted key in Stripe too (Developers → API keys).' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
349
+ const c = w(['secret', 'delete', 'STRIPE_SHOP_LINKS'], { input: 'y\n' });
350
+ return a.code === 0 || b.code === 0 || c.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Worker payment connection was removed. Ask your connected AI to deactivate existing Payment Links in Stripe too; already opened Stripe checkouts can still complete.' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
347
351
  }
348
352
 
349
353
  /** Lines for a person (the CLI's print). */
@@ -352,6 +356,7 @@ export function shopLines(r) {
352
356
  if (r.command === 'shop') {
353
357
  lines.push(r.ready ? `The shop is open${r.mode === 'test' ? ' in TEST MODE (no real money)' : ', live'} (till: ${r.till}).` : 'The shop is not selling yet:');
354
358
  for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
359
+ if (r.connection) lines.push(`Stripe test credentials expire ${r.connection.expiresAt}. Renew: ${r.connection.renew}`);
355
360
  if (r.totals) lines.push(`Last 30 days: ${r.totals.sales} sales, ${money(r.totals.paid, r.totals.currency)} before tax and fees; ${r.totals.refunds} refunded; ${r.totals.disputes} disputed.`);
356
361
  lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
357
362
  if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
@@ -373,6 +378,9 @@ export function shopLines(r) {
373
378
  if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
374
379
  else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
375
380
  if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
376
- } else lines.push(r.message ?? '');
381
+ } else { lines.push(r.message ?? r.why ?? (r.alreadyConnected ? 'Stripe test credentials are already installed.' : '')); for (const step of r.next ?? []) lines.push(step); if (r.fallback) lines.push(`Optional fallback, only if chosen: ${r.fallback}`); }
377
382
  return lines;
378
383
  }
384
+
385
+ // Public entry point: browser pairing by default; the local key page is explicitly opt-in.
386
+ export { shopConnect } from './stripe-connect.mjs';
@@ -0,0 +1,135 @@
1
+ /** Stripe owns browser authorization. This module owns only secret transport and a non-secret receipt.
2
+ * See docs/stripe-connect-research.md: current CLI OAuth is NOT a Worker credential issuer. */
3
+ import { spawn } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { runner } from './cloudflare.mjs';
9
+ import { lockDeploy } from './deploy-state.mjs';
10
+ import { readStudio, siteUrl } from './studio.mjs';
11
+ import { hookUrlOf, readShop, shopConnectManual } from './shop.mjs';
12
+ import { syncPaymentLinks } from './shop-links.mjs';
13
+ import { LINKS_STRIPE_VERSION, fullerReason } from '../worker/shop-links.mjs';
14
+ import { formEncode } from '../worker/stripe.mjs';
15
+
16
+ const hash = (s) => createHash('sha256').update(s).digest('hex');
17
+ const failure = (needs, why, extra = {}) => ({ ok: false, command: 'shop connect', needs, why, ...extra });
18
+ const receiptHome = () => join(homedir(), '.config', 'homie', 'stripe');
19
+ const receiptDir = (root, storage) => join(storage, hash(realpathSync(root)).slice(0, 24));
20
+
21
+ /** Local expiry evidence only; no account query and no credentials in the result. */
22
+ export function stripeConnectionInfo(root, { storage = receiptHome(), now = Date.now } = {}) {
23
+ try {
24
+ const r = JSON.parse(readFileSync(join(receiptDir(root, storage), 'test.json'), 'utf8'));
25
+ if (r.url !== hookUrlOf(siteUrl(root)) || !Number.isFinite(Date.parse(r.expiresAt))) return null;
26
+ return { mode: 'test', expiresAt: r.expiresAt, expired: Date.parse(r.expiresAt) <= now(), renew: 'homie-studio shop connect --renew' };
27
+ } catch { return null; }
28
+ }
29
+
30
+ const json = (s) => { try { return JSON.parse(s); } catch { return null; } };
31
+ // This output stays private, including CLI config, completion, errors and API responses.
32
+ export function stripeProcess(args, { cwd, env = process.env } = {}) {
33
+ const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
34
+ for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
35
+ return new Promise((resolve) => {
36
+ const p = spawn('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
37
+ let stdout = '', stderr = '', ended = false;
38
+ const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
39
+ const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
40
+ p.stdout.on('data', (b) => { stdout += b; if (stdout.length > 4_000_000) p.kill(); });
41
+ p.stderr.on('data', (b) => { stderr += b; if (stderr.length > 1_000_000) p.kill(); });
42
+ p.on('error', (e) => end({ code: 1, missing: e.code === 'ENOENT' }));
43
+ p.on('close', (code) => end({ code: code ?? 1, stdout, stderr }));
44
+ });
45
+ }
46
+
47
+ function openBrowser(url) {
48
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
49
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
50
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
51
+ p.on('error', () => {}); p.unref();
52
+ }
53
+
54
+ const stripeURL = (s) => {
55
+ try { const u = new URL(s); return u.protocol === 'https:' && ['dashboard.stripe.com', 'access.stripe.com'].includes(u.hostname) && !u.username && !u.password && !u.port; } catch { return false; }
56
+ };
57
+ /** Accept only Stripe's known login continuations. Never execute next_step as shell text. */
58
+ export function completionArgs(step) {
59
+ if (step === 'stripe login --complete-device') return ['login', '--complete-device'];
60
+ if (step === 'stripe login --complete-reauth') return ['login', '--complete-reauth'];
61
+ const m = /^stripe login --complete[ =](?:'([^']+)'|"([^"]+)"|(\S+))$/.exec(step ?? '');
62
+ const url = m && (m[1] || m[2] || m[3]);
63
+ return stripeURL(url) ? ['login', '--complete', url] : null;
64
+ }
65
+
66
+ export async function shopConnect(root, options = {}) {
67
+ if (options.manual) {
68
+ const result = await shopConnectManual(root, options);
69
+ if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
70
+ return result;
71
+ }
72
+ const { live = false, renew = false, log = () => {}, exec = stripeProcess, open = openBrowser,
73
+ storage = receiptHome(), wrangler = null } = options;
74
+ const mode = live ? 'live' : 'test';
75
+ const checked = readShop(root);
76
+ if (!checked.ok || checked.absent) return failure('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
77
+ if (options.managed === true && checked.shop.till !== 'stripe-managed') return failure('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
78
+ const id = hash(realpathSync(root)).slice(0, 24);
79
+ const profile = `homie-${id}`;
80
+ const dir = join(storage, id);
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ // Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
83
+ const lock = lockDeploy(dir);
84
+ if (!lock.ok) return failure('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
85
+ try {
86
+ const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
87
+ const installed = await cli(['--version']);
88
+ if (installed.code !== 0) return failure('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
89
+
90
+ // A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
91
+ let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
92
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) {
93
+ const start = await cli(['login', '--non-interactive', ...(renew ? ['--new-session'] : [])]);
94
+ const login = json(start.stdout);
95
+ const complete = completionArgs(login?.next_step);
96
+ if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
97
+ return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.');
98
+ }
99
+ log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
100
+ open(login.browser_url);
101
+ const finished = await cli(complete);
102
+ if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.');
103
+
104
+ probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
105
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.');
106
+ }
107
+ const site = siteUrl(root);
108
+ if (!site) return failure('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
109
+ let target;
110
+ try { target = new URL(site); } catch { return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
111
+ if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
112
+ const api = async (method, path, params, idempotency) => {
113
+ const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
114
+ for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
115
+ const r = await cli(args);
116
+ const body = json(r.stdout);
117
+ if (r.code !== 0 || !body || body.error) throw new Error('Stripe API operation failed');
118
+ return body;
119
+ };
120
+ const reason = fullerReason(checked.shop);
121
+ if (reason) return failure('fuller-checkout', reason, { mode, connected: true });
122
+ const studio = readStudio(root);
123
+ const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
124
+ const receiptFile = join(dir, `${mode}.json`);
125
+ const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
126
+ const result = await syncPaymentLinks({ api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
127
+ saveReceipt: (record) => {
128
+ writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
129
+ renameSync(`${receiptFile}.tmp`, receiptFile);
130
+ } });
131
+ return { ...result, profile };
132
+ } catch {
133
+ return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.');
134
+ } finally { lock.release(); }
135
+ }
@@ -1822,6 +1822,90 @@
1822
1822
  }
1823
1823
  }
1824
1824
  }
1825
+ },
1826
+ "0.34.0": {
1827
+ "files": {
1828
+ "CLAUDE.md": "ddf2b2a0376202f6",
1829
+ "README.md": "684b3cca8001673c",
1830
+ "HANDOFF.md": "29337e2ace95b2e7",
1831
+ "games/README.md": "5ad32fe80c61f117",
1832
+ "music/README.md": "a3dc042dadd39df8",
1833
+ "videos/README.md": "16babc3437d5545b",
1834
+ "posts/README.md": "37841aae476bd540",
1835
+ "site/README.md": "732894273a32dc3f",
1836
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1837
+ },
1838
+ "sections": {
1839
+ "AGENTS.md": {
1840
+ "(intro)": "214aa46eb3a895d5",
1841
+ "## Layout": "8baf49e4291e7e88",
1842
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1843
+ "## The site": "0cb8dd9a6f8a0096",
1844
+ "## Making games": "2da7fa74c8884834",
1845
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1846
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1847
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1848
+ "## Servers and AI seats": "98d08705087a33ec",
1849
+ "## Selling things (the shop)": [
1850
+ "57c04f6526e16890",
1851
+ "6c30b932b0783495",
1852
+ "9e020d69dd6a954e"
1853
+ ],
1854
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1855
+ "## Rules": "2e6b29afb85dcf7e",
1856
+ "## Telling Homie": "80b46fdd9e578db8",
1857
+ "## Beta": "9d02638207a26e73"
1858
+ }
1859
+ },
1860
+ "named": {
1861
+ "Homie Arcade": {
1862
+ "sections": {
1863
+ "AGENTS.md": {
1864
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1865
+ }
1866
+ }
1867
+ }
1868
+ }
1869
+ },
1870
+ "0.35.0": {
1871
+ "files": {
1872
+ "CLAUDE.md": "ddf2b2a0376202f6",
1873
+ "README.md": "684b3cca8001673c",
1874
+ "HANDOFF.md": "29337e2ace95b2e7",
1875
+ "games/README.md": "5ad32fe80c61f117",
1876
+ "music/README.md": "a3dc042dadd39df8",
1877
+ "videos/README.md": "16babc3437d5545b",
1878
+ "posts/README.md": "37841aae476bd540",
1879
+ "site/README.md": "732894273a32dc3f",
1880
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1881
+ },
1882
+ "sections": {
1883
+ "AGENTS.md": {
1884
+ "(intro)": "214aa46eb3a895d5",
1885
+ "## Layout": "8baf49e4291e7e88",
1886
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1887
+ "## The site": "0cb8dd9a6f8a0096",
1888
+ "## Making games": "2da7fa74c8884834",
1889
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1890
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1891
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1892
+ "## Servers and AI seats": "98d08705087a33ec",
1893
+ "## Selling things (the shop)": "9e020d69dd6a954e",
1894
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1895
+ "## Rules": "2e6b29afb85dcf7e",
1896
+ "## Telling Homie": "80b46fdd9e578db8",
1897
+ "## Beta": "9d02638207a26e73"
1898
+ }
1899
+ },
1900
+ "named": {
1901
+ "Homie Arcade": {
1902
+ "sections": {
1903
+ "AGENTS.md": {
1904
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1905
+ }
1906
+ }
1907
+ }
1908
+ }
1825
1909
  }
1826
1910
  }
1827
1911
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.33.1",
3
+ "version": "0.35.0",
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",
package/shop/SELLING.md CHANGED
@@ -1,97 +1,21 @@
1
- # Selling things in your games: what you are taking on
1
+ # Your shop, your choices
2
2
 
3
- Plain words for a studio's owner, from the Homie studio kit. **This is not legal or tax advice.** If you will
4
- sell for real, ask an accountant where you must register for tax, and a lawyer anything this page leaves you
5
- unsure about.
3
+ You sell with your own Stripe account. Money goes directly to you; Homie takes no cut.
4
+ The studio is the seller and is responsible for the law where it sells and for Stripe's terms.
6
5
 
7
- ## The short version
6
+ Your `shop.json` chooses items, wording, prices, currency, tax integration, sale dates, entitlements,
7
+ referral terms, optional spending caps and optional refund windows. Nothing requires an account or age
8
+ question by default. Guests can buy repeatedly and keep their purchases when signing in later.
8
9
 
9
- You sell with **your own Stripe account**. Money goes straight from your players to you. Homie never sees it,
10
- never holds it, and takes no cut. That also means **you are the shop**: you set the prices, you answer refund
11
- requests, you deal with card disputes, and the tax on your sales is yours to handle.
10
+ You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
11
+ Without a preset the shop is open. SHOP.md describes each setting and the game cart API.
12
12
 
13
- ## Who does what
13
+ With the fuller checkout connection, a cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
14
+ A player can refund items within a window you set; a tip stays given unless you refund it.
15
+ Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
16
+ Managed Payments is another selectable integration. Your office links Stripe's own pages.
14
17
 
15
- | | You (the studio) | Stripe | Homie |
16
- |---|---|---|---|
17
- | Prices, what you sell | yes | | |
18
- | Taking the card, the receipt email | | yes | |
19
- | Refunds | yes: one tap in your office | carries them out | |
20
- | Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
21
- | Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
22
- | Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
23
- | Who may buy | your editable shop policy; protective defaults | provider terms | supplies presets |
18
+ `homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Buy different items separately. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
24
19
 
25
- ## Refunds
26
-
27
- - A player can refund an **unused** item themselves within the optional refund window you set from their
28
- account page. Set `policy.refundUsedItems` to include used items. Without a window, buyers ask you for a refund.
29
- Fewer people turn to card disputes when a refund is easy.
30
- - Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
31
- can never do it by itself.
32
- - Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
33
-
34
- ## Disputes (chargebacks)
35
-
36
- - A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
37
- you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
38
- sale, so consider your costs when setting prices. Homie imposes no price or earnings ceiling.
39
- - **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
40
- that one item is taken back, as a refund would.
41
-
42
- ## Tax
43
-
44
- You choose who the seller is when you connect your Stripe (`homie-studio shop connect` shows both):
45
-
46
- - **You are the seller** (`"till": "stripe"`): Stripe Tax is on. It works out and collects tax **only where you
47
- have told Stripe you are registered**, and it does not file your returns. Some places expect a foreign seller
48
- of digital goods to register from the first sale (the EU and the UK do). Cheapest; the most work.
49
- - **Stripe is the seller** (`"till": "stripe-managed"`, Stripe Managed Payments): **3.5% more** a sale. Stripe's
50
- Link, LLC becomes the seller of record and registers, collects, files and pays sales tax and VAT in 80+
51
- countries, runs fraud checks and answers disputes for you. You still carry the money of a lost dispute, and
52
- Stripe may refund a buyer within 60 days. You turn it on in Stripe first (after Stripe's eligibility review).
53
-
54
- ## Kids
55
-
56
- Your shop starts with the `protective` preset in shop.json. It requires an account and a neutral age question,
57
- blocks purchases under 13, uses a parent's checkout for teens, closes shops on kids servers and kids studios,
58
- hides advantages on beginner and kids servers, refuses paid randomness, countdowns and virtual-currency items,
59
- and sends TV shoppers to a phone. Existing shops without a policy keep these protections.
60
-
61
- These are **your settings**: read and edit `policy` in shop.json. `adults-only` also blocks teens;
62
- `custom` enables all policies. You can override individual rules without changing the others. SHOP.md lists
63
- every setting. No spending cap or refund window applies unless you set one; existing written values stay yours.
64
-
65
- As the seller, you are responsible for the law and your payment provider's terms wherever you sell, including
66
- rules protecting children. Choosing a preset does not establish compliance. This is information, not legal advice.
67
-
68
- ## Referrals
69
-
70
- If another studio (or homie.rocks, or a creator) sends you a new player who buys something, you owe them the
71
- referral share you published in `shop.json` (10% if you kept the suggestion; 0 if you turn it off). Your office
72
- shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
73
- yourself.** Nothing moves through Homie.
74
-
75
- ## What you do, and what your AI does
76
-
77
- Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
78
- makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
79
- your Stripe. What stays yours, in Stripe's own pages:
80
-
81
- - making the Stripe account, and later activating it (your business details, bank account and identity checks);
82
- - one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
83
- - one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
84
- - approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
85
- in your office.
86
-
87
- If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
88
- Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
89
- restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
90
-
91
- ## Before the first real sale
92
-
93
- 1. Make (or pick) your Stripe account, verify it, and add a bank account.
94
- 2. Test everything in **test mode** first: buy your own item with a Stripe test card, refund it from the office.
95
- 3. Fill in your public business details in Stripe (name, support email, the refund page `/shop/refunds/`).
96
- 4. Talk to an accountant about where you must register for tax, or choose Managed Payments.
97
- 5. Then, and only then, connect a live key (`homie-studio shop connect --live`).
20
+ The fuller connection adds combined carts, spending limits, custom checkout expiry, automatic recovery and refunds in the office. An existing Worker key selects it; your AI explains it only if you ask for those features. The optional manual setup remains available, but is not the default.
21
+ The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.