@homie-rocks/studio 0.24.2 → 0.24.3

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
@@ -7,11 +7,15 @@
7
7
  * writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
8
8
  * homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
9
9
  * homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
10
- * pastes the studio's restricted Stripe key and webhook signing secret; they
11
- * go straight to the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET on
12
- * Wrangler's standard input: never a chat, a file, an argument or a log.
13
- * The page also offers Stripe Managed Payments in plain words. Test keys
14
- * only, unless --live (then live keys only).
10
+ * pastes the studio's restricted Stripe key (the one thing only they can make);
11
+ * with it this process makes the webhook (0.24.3), and the key and the
12
+ * webhook's signing secret go straight to the Worker secrets STRIPE_KEY and
13
+ * STRIPE_WEBHOOK_SECRET on Wrangler's standard input: never a chat, a file,
14
+ * an argument or a log. The page also offers Stripe Managed Payments in plain
15
+ * words (and in test mode tries one test checkout with it). Test keys only,
16
+ * unless --live (then live keys only).
17
+ * homie-studio shop catalog [--have <file>|-] [--mode test|live] the items as Products in Stripe, made through
18
+ * Stripe's own MCP (lib/shop-catalog.mjs): the read to make, then the writes
15
19
  * homie-studio shop disconnect removes both secrets (the shop closes at once)
16
20
  * homie-studio shop orders the latest orders (never a card or an email)
17
21
  * homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
@@ -28,8 +32,11 @@ import { fileURLToPath } from 'node:url';
28
32
  import { askedFor, withKey } from './office.mjs';
29
33
  import { runner } from './cloudflare.mjs';
30
34
  import { listGames, readStudio, siteUrl } from './studio.mjs';
31
- import { SHOP_FILE, audienceOf, checkShop, money } from '../worker/shop-rules.mjs';
32
- import { KEY_SHAPE, WEBHOOK_SECRET_SHAPE, apiBase, modeOf } from '../worker/stripe.mjs';
35
+ import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
36
+ import {
37
+ KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
38
+ stripeCall, updateWebhookEndpoint,
39
+ } from '../worker/stripe.mjs';
33
40
 
34
41
  const HERE = dirname(fileURLToPath(import.meta.url));
35
42
  const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
@@ -44,6 +51,7 @@ export const KEY_PERMISSIONS = Object.freeze([
44
51
  ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
45
52
  ['PaymentIntents', 'Read', 'which payment an order was'],
46
53
  ['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
54
+ ['Webhook Endpoints', 'Write', 'only so this page makes the webhook for you; you can set it back to None afterwards'],
47
55
  ]);
48
56
 
49
57
  /** shop.json read and checked with the studio's games and audience: { ok, shop, errors, warnings, file }. */
@@ -126,46 +134,105 @@ export async function shopStatements(root, { url, period, send = false } = {}) {
126
134
 
127
135
  const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
128
136
 
129
- /** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, two fields, one button. */
137
+ /** Where Stripe sends the shop's events, on the studio's live site. */
138
+ export const hookUrlOf = (site) => `${String(site).replace(/\/+$/, '')}/api/shop/hook`;
139
+ const HOOK_MARK = 'shop-v1';
140
+
141
+ /** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, one field, one button. */
130
142
  export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
131
143
  const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
132
144
  const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
133
145
  const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
134
146
  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>
135
- <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}</style>
147
+ <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>
136
148
  <h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
137
- <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 your studio's Worker as two secrets, and nowhere else: not to the chat, not to a file, not to a log.</p>
138
- <p class="box">${test ? '<b>Test mode first.</b> Use your Stripe account in test mode (the keys start with <code>rk_test_</code>): nothing real is charged, and test cards work. Switch to live keys only when the house is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
139
- <h2>1. In Stripe: a restricted key</h2>
140
- <ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>.</li>
149
+ <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>
150
+ <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>
151
+ <h2>1. In Stripe: one restricted key</h2>
152
+ <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>
141
153
  <li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
142
154
  <li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
143
- <h2>2. In Stripe: where Stripe tells your shop about payments</h2>
144
- <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>
145
- <li>Endpoint URL: <code>${esc(site)}/api/shop/hook</code></li>
146
- <li>Events: ${events}</li>
147
- <li>Create it, press <b>Reveal</b> on the signing secret, and copy it (it starts with <code>whsec_</code>).</li></ol>
155
+ <h2>2. The webhook: this page makes it</h2>
156
+ <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>
148
157
  <form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
149
- <h2>3. Paste both here</h2>
158
+ <h2>3. Paste the key here</h2>
150
159
  <label for="key">Restricted key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
151
- <label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…" required>
160
+ <details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
161
+ <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>
162
+ <li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
163
+ <li>Create it, press <b>Reveal</b> on the signing secret, and paste it here (it starts with <code>whsec_</code>).</li></ol>
164
+ <label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
152
165
  <h2>4. Who is the seller?</h2>
153
166
  <fieldset>
154
167
  <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>
155
- <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).</span></label>
168
+ <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>
156
169
  </fieldset>
157
170
  <button>Save to my Worker</button></form>
158
171
  <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>`;
159
172
  }
160
173
 
174
+ const said = (status, text) => ({ status, text });
175
+
176
+ /**
177
+ * The webhook, made on the owner's computer with the key they just pasted: Stripe answers the new endpoint's signing
178
+ * secret once, and it goes straight to the Worker secret. Never printed, never returned. Older endpoints this kit made
179
+ * for the same address are turned off (not deleted) once the new secret is saved. { ok, id, secret, older } or
180
+ * { ok: false, page } (what the page tells the owner).
181
+ */
182
+ async function makeWebhook(env, site, { fetcher }) {
183
+ const url = hookUrlOf(site);
184
+ let older = [];
185
+ try {
186
+ const list = await listWebhookEndpoints(env, { fetcher });
187
+ older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
188
+ } catch (error) {
189
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
190
+ return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
191
+ }
192
+ let made = null;
193
+ try {
194
+ made = await createWebhookEndpoint(env, {
195
+ url, enabled_events: [...HOOK_EVENTS], api_version: STRIPE_VERSION,
196
+ description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
197
+ }, { fetcher });
198
+ } catch (error) {
199
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
200
+ return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
201
+ }
202
+ const secret = typeof made?.secret === 'string' ? made.secret : '';
203
+ if (!WEBHOOK_SECRET_SHAPE.test(secret) || typeof made?.id !== 'string') return { ok: false, page: said(502, 'Stripe made a webhook but did not hand back its signing secret. Nothing was saved. Make the webhook yourself and paste its secret under "I made the webhook myself".') };
204
+ return { ok: true, id: made.id, secret, older };
205
+ }
206
+
207
+ /**
208
+ * Managed Payments, tried once in TEST mode before the first buyer: a test Checkout Session with
209
+ * `managed_payments[enabled]`, expired at once. Stripe's answer says whether Managed Payments is on for this account
210
+ * (there is no API to read it). Never in live mode.
211
+ */
212
+ async function probeManaged(env, shop, { fetcher }) {
213
+ const item = shop?.items?.find((i) => i.kind !== 'tip') ?? null;
214
+ try {
215
+ const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
216
+ mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
217
+ line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
218
+ managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
219
+ }, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
220
+ if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
221
+ return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
222
+ } catch (error) {
223
+ return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
224
+ }
225
+ }
226
+
161
227
  /**
162
- * `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key and the secret go straight
163
- * to `wrangler secret put` on its standard input. The command never prints them; it checks only their shape (and,
164
- * when `verify` is on, that Stripe accepts the key with one read).
228
+ * `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
229
+ * read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
230
+ * which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
165
231
  */
166
232
  export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
167
233
  const studio = readStudio(root);
168
- const site = siteUrl(root) ?? 'https://<your studio>';
234
+ const site = siteUrl(root);
235
+ 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' };
169
236
  const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
170
237
  const w = runner(root, env);
171
238
  const shopNow = readShop(root);
@@ -174,6 +241,7 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
174
241
  const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
175
242
  return await new Promise((done) => {
176
243
  let finished = false;
244
+ let busy = false;
177
245
  const server = createServer((req, res) => {
178
246
  if (req.method === 'GET' && req.url === `/${nonce}`) { res.writeHead(200, { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'referrer-policy': 'no-referrer' }); res.end(page); return; }
179
247
  if (req.method === 'POST' && req.url === '/key') {
@@ -182,43 +250,66 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
182
250
  req.on('end', async () => {
183
251
  const form = new URLSearchParams(body);
184
252
  const key = String(form.get('key') ?? '').trim();
185
- const hook = String(form.get('hook') ?? '').trim();
253
+ let hook = String(form.get('hook') ?? '').trim();
186
254
  const chosen = form.get('till') === 'stripe-managed' ? 'stripe-managed' : 'stripe';
187
- const say = (status, text) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
188
- if (form.get('n') !== nonce || finished) { say(403, 'This page was used already.'); return; }
189
- if (/^sk_live_/.test(key)) { say(400, 'That is a full secret key. The shop takes only a RESTRICTED key (rk_…) with the four permissions on the page. Go back and make one.'); return; }
190
- if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.'); return; }
255
+ const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
256
+ if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
257
+ if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
258
+ 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; }
259
+ 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; }
191
260
  // Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
192
- if (modeOf(key) !== (live ? 'live' : 'test')) { say(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; }
193
- if (!WEBHOOK_SECRET_SHAPE.test(hook)) { say(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again.'); return; }
194
- finished = true;
195
- // One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
196
- // except to Stripe itself.
197
- if (verify) {
198
- let ok = false;
199
- try {
200
- const r = await fetcher(`${apiBase(process.env)}/v1/checkout/sessions?limit=1`, { headers: { authorization: `Bearer ${key}` }, signal: AbortSignal.timeout(10_000) });
201
- ok = r.ok;
202
- } catch { ok = false; }
203
- if (!ok) { finished = false; say(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.'); return; }
204
- }
205
- const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
206
- const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
207
- const okay = a.code === 0 && b.code === 0;
208
- let tillNote = '';
209
- if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
210
- try {
211
- const raw = JSON.parse(readFileSync(shopNow.file, 'utf8'));
212
- raw.till = chosen;
213
- writeFileSync(shopNow.file, `${JSON.stringify(raw, null, 2)}\n`);
214
- tillNote = ` shop.json now says "till": "${chosen}" (deploy to use it).`;
215
- } catch { tillNote = ''; }
216
- }
217
- say(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode). You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.');
218
- server.close();
219
- done(okay
220
- ? { ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen, 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'}.${tillNote} homie-studio shop says whether anything else is missing.` }
221
- : { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
261
+ 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; }
262
+ 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; }
263
+ busy = true;
264
+ try {
265
+ const sk = { STRIPE_KEY: key, STRIPE_API_BASE: process.env.STRIPE_API_BASE };
266
+ // One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
267
+ // except to Stripe itself.
268
+ if (verify) {
269
+ try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
270
+ say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
271
+ }
272
+ }
273
+ let webhook = { made: false };
274
+ if (!hook) {
275
+ const m = await makeWebhook(sk, site, { fetcher });
276
+ if (!m.ok) { say(m.page); return; }
277
+ hook = m.secret;
278
+ webhook = { made: true, id: m.id, older: m.older };
279
+ }
280
+ finished = true;
281
+ const a = w(['secret', 'put', 'STRIPE_KEY'], { input: `${key}\n` });
282
+ const b = a.code === 0 ? w(['secret', 'put', 'STRIPE_WEBHOOK_SECRET'], { input: `${hook}\n` }) : { code: 1 };
283
+ hook = '';
284
+ const okay = a.code === 0 && b.code === 0;
285
+ // The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
286
+ // not deleted: the owner can turn one back on in Stripe).
287
+ let disabled = 0;
288
+ if (okay && webhook.made) {
289
+ for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
290
+ }
291
+ const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
292
+ let tillNote = '';
293
+ if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
294
+ try {
295
+ const raw = JSON.parse(readFileSync(shopNow.file, 'utf8'));
296
+ raw.till = chosen;
297
+ writeFileSync(shopNow.file, `${JSON.stringify(raw, null, 2)}\n`);
298
+ tillNote = ` shop.json now says "till": "${chosen}" (deploy to use it).`;
299
+ } catch { tillNote = ''; }
300
+ }
301
+ 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.' : '';
302
+ 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 ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
303
+ server.close();
304
+ done(okay
305
+ ? {
306
+ ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
307
+ webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
308
+ ...(check ? { managedPayments: check } : {}),
309
+ 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.`,
310
+ }
311
+ : { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
312
+ } finally { busy = false; }
222
313
  });
223
314
  return;
224
315
  }
@@ -1078,6 +1078,45 @@
1078
1078
  }
1079
1079
  }
1080
1080
  }
1081
+ },
1082
+ "0.24.3": {
1083
+ "files": {
1084
+ "CLAUDE.md": "ddf2b2a0376202f6",
1085
+ "README.md": "684b3cca8001673c",
1086
+ "HANDOFF.md": "ce7a8fd91c6bbee2",
1087
+ "games/README.md": "5ad32fe80c61f117",
1088
+ "music/README.md": "a3dc042dadd39df8",
1089
+ "videos/README.md": "16babc3437d5545b",
1090
+ "posts/README.md": "37841aae476bd540",
1091
+ "site/README.md": "ca131cd0472464fb",
1092
+ "site/src/worker.mjs": "ca3f1630f2420ad3"
1093
+ },
1094
+ "sections": {
1095
+ "AGENTS.md": {
1096
+ "(intro)": "214aa46eb3a895d5",
1097
+ "## Layout": "8baf49e4291e7e88",
1098
+ "## Commands (all through the pinned CLI in node_modules)": "d80ec56e4b4fc4c6",
1099
+ "## The site": "ab922bb6bde23293",
1100
+ "## Making games": "a8a893eeada33bf5",
1101
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1102
+ "## Art direction and models": "124772c374de7390",
1103
+ "## Running live games (the back office)": "4aa5f513a7cd2c3b",
1104
+ "## Servers and AI seats": "0a332b07e22d3fce",
1105
+ "## Selling things (the shop)": "b04b0a2b25f524f2",
1106
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1107
+ "## Rules": "7e8458adc1b81ece",
1108
+ "## Beta": "9d02638207a26e73"
1109
+ }
1110
+ },
1111
+ "named": {
1112
+ "Homie Arcade": {
1113
+ "sections": {
1114
+ "AGENTS.md": {
1115
+ "## Commands (all through the pinned CLI in node_modules)": "b35df2c19cd4b534"
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1081
1120
  }
1082
1121
  }
1083
1122
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.24.2",
3
+ "version": "0.24.3",
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
@@ -74,6 +74,22 @@ referral share you published in `shop.json` (10% if you kept the suggestion; 0 i
74
74
  shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
75
75
  yourself.** Nothing moves through Homie.
76
76
 
77
+ ## What you do, and what your AI does
78
+
79
+ Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
80
+ makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
81
+ your Stripe. What stays yours, in Stripe's own pages:
82
+
83
+ - making the Stripe account, and later activating it (your business details, bank account and identity checks);
84
+ - one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
85
+ - one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
86
+ - approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
87
+ in your office.
88
+
89
+ If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
90
+ Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
91
+ restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
92
+
77
93
  ## Before the first real sale
78
94
 
79
95
  1. Make (or pick) your Stripe account, verify it, and add a bank account.
package/shop/SHOP.md CHANGED
@@ -1,6 +1,7 @@
1
1
  # The shop: selling in a studio's games with the studio's own Stripe
2
2
 
3
- `@homie-rocks/studio` 0.24.0. A studio sells items for its games through **its own Stripe account**. The studio
3
+ `@homie-rocks/studio` 0.24.0, set up with Stripe's own agent tools since 0.24.3. A studio sells items for its games
4
+ through **its own Stripe account**. The studio
4
5
  is the seller; homie.rocks never holds, routes or settles its money, is not a Stripe Connect platform, and takes
5
6
  no cut. There is no shared currency between studios. `SELLING.md` beside this file is the owner's plain-words
6
7
  note on what selling means for them (refunds, disputes, tax, kids; not legal advice).
@@ -40,6 +41,7 @@ At the studio's root, reviewed in git. Nothing secret is ever in it.
40
41
  | `items[].advantage` | `true` when it changes how the game plays: never offered, and never counted as owned, on a beginner or kids server. |
41
42
  | `items[].taxCode` | a Stripe product tax code. Managed Payments needs one: by default `txcd_10201000` (video games, downloaded, permanent) or `txcd_10201001` (limited time). |
42
43
  | `referrals` | what this studio pays a referrer for a new player's purchase (left out: nothing). |
44
+ | `catalog` | where the items are Products in Stripe: `["test"]`, `["test", "live"]`. `homie-studio shop catalog` writes it once Stripe's catalog matches; the Worker's checkouts then name each item's Product (0.24.3). |
43
45
 
44
46
  `homie-studio shop check`, and every `homie-studio build`, refuse: anything that names chance, odds, random, a
45
47
  crate, a box, a mystery, loot, a gacha, a spin or a roll; fields for odds or drop pools; a countdown, timer or
@@ -107,23 +109,59 @@ homie.rocks: not involved at any step.
107
109
  - Each Checkout Session says beside the pay button that the item is delivered at once (the EU and UK withdrawal
108
110
  acknowledgement) and that an unused item can still be refunded.
109
111
 
110
- ## Setting it up (Claude does the commands; the owner only uses pages)
112
+ ## Setting it up (the AI does the commands; the owner only uses Stripe's pages and one page here)
113
+
114
+ The AI works with **Stripe's own agent tools** (0.24.3): Stripe's agent plugin (`npm install -g @stripe/cli@latest &&
115
+ stripe agent setup`: Stripe's MCP server at `https://mcp.stripe.com` and Stripe's skills, for Claude Code and Codex) or
116
+ Stripe's connector in the Claude app. The owner signs it in once on Stripe's consent page and gives it **a sandbox
117
+ first**. An agent that cannot do OAuth uses an **Agent key** (a restricted key Stripe tags "Agent") from its
118
+ environment; from **2026-10-31** Stripe's MCP refuses full secret keys and restricted keys without that tag. The shop's
119
+ own key, in the Worker, is a plain restricted key and is not affected.
120
+
121
+ What only the owner does: make the Stripe account and (for live) activate it: business details, bank, identity, and,
122
+ for Managed Payments, its terms and Stripe's eligibility review; approve Stripe's sign-in page; make **one restricted
123
+ key** in Stripe's Dashboard (Stripe has no API that makes API keys) and paste it on the connect page; approve any
124
+ write Stripe sends to them for confirmation (a refund through Stripe's MCP, for one).
111
125
 
112
126
  1. `npx --no-install homie-studio shop init --supporter` (shop.json and SELLING.md), then `shop check`, commit,
113
127
  `npm run deploy` (it applies migration `0008_studio_shop.sql`; the shop stays closed).
114
- 2. In Stripe (the owner): a restricted key with **Checkout Sessions: Write, Charges: Write (refunds),
115
- PaymentIntents: Read, Disputes: Read**, and a webhook endpoint at `https://<site>/api/shop/hook` for
116
- `checkout.session.completed`, `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`,
117
- `checkout.session.expired`, `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`,
118
- `charge.dispute.closed`.
119
- 3. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes)
120
- with those steps, both fields and the seller choice (the studio, or Stripe Managed Payments, with what each
121
- costs and does). The key and the secret go straight to the Worker secrets `STRIPE_KEY` and
122
- `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input. Test keys only, unless `--live`.
123
- 4. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
124
- refund it from `/_studio/office/shop`.
125
-
126
- Never ask for a key in a chat, never put one in a file, an argument, a log or a commit.
128
+ 2. **The catalog**, through Stripe's MCP: `homie-studio shop catalog` names the one read to make
129
+ (`stripe_api_read GET /v1/products`, expanding each default price); `homie-studio shop catalog --have <the saved
130
+ answer>` lists exactly the `stripe_api_write` calls still needed: each item a Product with the id
131
+ `homie_<studio slug>_<item id>` (the same in a sandbox and live), its name, blurb, a tax code (video games,
132
+ downloaded: `txcd_10201000`, or `txcd_10201001` for an item that lasts days or ends on a date; eligible for
133
+ Managed Payments) and metadata naming the studio and the item, with a default Price of shop.json's amount (a tip:
134
+ the Product only). A changed price is a new Price made the default; an item gone from shop.json is archived, never
135
+ deleted; a product the studio did not make is never named. When Stripe matches, it says so and writes
136
+ `"catalog": ["test"]` into shop.json (commit, deploy). shop.json's checked price is always what is charged; the
137
+ checkout names the Product so Stripe's Dashboard, reports and MCP see sales by product, and a Product missing in
138
+ this mode falls back to the item described inline.
139
+ 3. **Tax**, read through Stripe's MCP: `GET /v1/tax/settings` (with the studio as the seller, Stripe Tax needs
140
+ `status: active`, the business address, or checkouts fail) and `GET /v1/tax/registrations` (none: Stripe Tax
141
+ collects nothing anywhere until the owner registers somewhere). Reading only; the owner and their accountant decide.
142
+ 4. `npx --no-install homie-studio shop connect`: a page on the owner's computer (127.0.0.1, one use, ten minutes). The
143
+ owner makes one restricted key with **Checkout Sessions: Write, Charges: Write (refunds), PaymentIntents: Read,
144
+ Disputes: Read, Webhook Endpoints: Write**, not an Agent key, and pastes it. With it the page reads once (a typo is
145
+ caught here), **makes the webhook** at `https://<site>/api/shop/hook` for `checkout.session.completed`,
146
+ `checkout.session.async_payment_succeeded`, `checkout.session.async_payment_failed`, `checkout.session.expired`,
147
+ `charge.refunded`, `refund.created`, `refund.updated`, `charge.dispute.created`, `charge.dispute.closed` (API version
148
+ `2025-03-31.basil`, metadata `homie: shop-v1`), and puts the key and the webhook's signing secret, which Stripe hands
149
+ back once, straight into the Worker secrets `STRIPE_KEY` and `STRIPE_WEBHOOK_SECRET` on Wrangler's standard input.
150
+ An older endpoint the kit made for the same address is turned off, never deleted. The page offers the seller
151
+ choice (the studio, or Stripe Managed Payments, with what each costs and does) and, in test mode with Managed
152
+ Payments, tries one test checkout with it (expired at once) and says whether Stripe took it. Afterwards the owner
153
+ may set the key's Webhook Endpoints back to None (recommended; the shop never needs it again). A key without
154
+ Webhook Endpoints: the page takes a webhook signing secret the owner made and revealed in Stripe instead. Test keys
155
+ only, unless `--live`.
156
+ 5. `homie-studio shop` says whether anything is still missing. Buy your own item with a Stripe test card, and
157
+ refund it from `/_studio/office/shop`. Through Stripe's MCP, `GET /v1/webhook_endpoints` shows the endpoint the
158
+ page made (its `we_…` id): if it is not there, the MCP is signed in to another account or sandbox than the key.
159
+ 6. **Live**, only when the owner says so: they activate the account, give Stripe's MCP access to the live account,
160
+ `shop catalog --mode live` makes the live catalog the same way, and `shop connect --live` takes a live key.
161
+
162
+ Never ask for a key in a chat, never put one in a file, an argument, a log or a commit. **Never make a webhook endpoint
163
+ or event destination through Stripe's MCP**: its answer carries the signing secret into the conversation (in Claude
164
+ Code the Homie mod refuses the call). Never use a Stripe CLI login or a key found on the computer.
127
165
 
128
166
  ## The office
129
167
 
@@ -133,6 +171,15 @@ to the studio's own Stripe Dashboard, a CSV for the accountant, and the referral
133
171
  refund (`POST /_studio/api/shop/refund`, `homie-studio shop refund <order>`) is always an ASK the owner confirms
134
172
  with one tap; so is marking a referrer paid.
135
173
 
174
+ A refund made anywhere else (in Stripe's Dashboard, or through Stripe's MCP after the owner approved Stripe's own
175
+ confirmation link) reaches the shop as `refund.created` / `charge.refunded`, and the item leaves the player's account
176
+ the same way. If the Worker's key is an Agent key, Stripe holds the Worker's refunds for a person's approval
177
+ (`approval_required`): the office answers 202 "held", nothing changes until someone approves it in Stripe (Settings,
178
+ Approvals), and the webhook finishes it. Reconnect with a plain restricted key to get the one tap back.
179
+
180
+ "How are sales?": `homie-studio shop` and `shop orders` (the studio's own books, no names), and through Stripe's MCP,
181
+ read-only, `stripe_analytics` or `stripe_api_read` on the balance, payouts and checkout sessions.
182
+
136
183
  ## Referrals
137
184
 
138
185
  - A link with `?via=<host>` (another studio, homie.rocks, anywhere) opened by a person whose browser has never
@@ -150,5 +197,7 @@ with one tap; so is marking a referrer paid.
150
197
  ## Testing without money
151
198
 
152
199
  The kit's tests run the Worker against `stripe-mock` (Stripe's open-source API mock, which checks every parameter
153
- against Stripe's own API description) and sign webhooks with a test secret. `STRIPE_API_BASE` (a loopback address
200
+ against Stripe's own API description) and sign webhooks with a test secret. stripe-mock also takes every write
201
+ `shop catalog` lists, a checkout that names its Product, and the webhook the connect page makes. Stripe's MCP is
202
+ never called by the tests: `shop catalog --have` reads an answer in the shapes Stripe's API documents. `STRIPE_API_BASE` (a loopback address
154
203
  only) points a Worker at such a mock; it is never honoured for anything but 127.0.0.1 or localhost.
package/worker/office.mjs CHANGED
@@ -1213,7 +1213,8 @@ async function api(request, env, url, cat) {
1213
1213
  }
1214
1214
  }
1215
1215
  const result = await perform(env, cat, action);
1216
- return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.error === 'no-player' ? 404 : 400);
1216
+ // A refund Stripe holds for a person's approval (an Agent-tagged key) is accepted, not refused: it happens there.
1217
+ return json({ ...result, what: describe(cat, action), by: who }, result.ok ? 200 : result.held ? 202 : result.error === 'no-player' ? 404 : 400);
1217
1218
  }
1218
1219
 
1219
1220
  /** /_studio/office, /_studio/confirm/<ask> and /_studio/api/*; null for any other path. */
@@ -206,16 +206,32 @@ export function checkShop(raw, { games = null, audience = 'general' } = {}) {
206
206
  }
207
207
  }
208
208
 
209
+ // Where `homie-studio shop catalog` made the items' Products in Stripe (0.24.3): the Worker's checkouts then name them.
210
+ let catalog = [];
211
+ if (raw.catalog !== undefined && raw.catalog !== null) {
212
+ if (!Array.isArray(raw.catalog) || raw.catalog.some((m) => m !== 'test' && m !== 'live')) err('catalog', 'catalog lists where the items are Products in Stripe: ["test"], ["test", "live"] (homie-studio shop catalog writes it)');
213
+ else catalog = [...new Set(raw.catalog)].sort();
214
+ }
215
+
209
216
  const kids = audience === 'kids';
210
217
  const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
211
218
  if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off whatever shop.json says).' });
212
219
  const shop = {
213
220
  v: 1, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items, referrals,
214
- open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}),
221
+ open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
215
222
  };
216
223
  return { ok: errors.length === 0, shop, errors, warnings };
217
224
  }
218
225
 
226
+ /**
227
+ * The Stripe product tax code an item gets when shop.json names none: video games, downloaded, permanent rights
228
+ * (txcd_10201000), or limited rights for an item that lasts a number of days or ends on a date (txcd_10201001).
229
+ * Managed Payments needs one of its eligible codes on every product; these two are.
230
+ */
231
+ export function defaultTaxCode(item) {
232
+ return item?.taxCode ?? (item?.days || item?.ends ? 'txcd_10201001' : 'txcd_10201000');
233
+ }
234
+
219
235
  /** studio.json "audience": "general" (the default), "teens" or "kids". */
220
236
  export function audienceOf(studio) {
221
237
  const a = studio?.audience;