@homie-rocks/studio 0.23.0 → 0.24.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,64 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.24.2] - 2026-10-03
21
+
22
+ **Plugin 0.25.2** · [#35](https://github.com/homie-rocks/homie/pull/35)
23
+
24
+ Homie's skills work through each provider's own CLI, plugin and MCP server, declared so they load only when a skill needs them, and the Homie mod holds the paid calls and Cloudflare changes those tools can make.
25
+
26
+ ### Added
27
+
28
+ - `providers.json` in the plugin: each provider's own CLI, plugin, MCP servers and skills, the skills that use them, and what stays Homie's (budgets and receipts, the kids rules, secrets never in the chat, the owner's one-tap asks, phone budgets, the rights notes). The plugin's `plugin.json` points at it (`extensions["rocks.homie"].providers`). Nothing installs by itself: a skill offers a provider's tool when the person wants what it unlocks, and the person approves the install and signs in on the provider's own page.
29
+ - Each skill that uses a provider names it in its own frontmatter (`metadata.providers`, and `compatibility` in words: the Agent Skills fields). The art, video, models, style, publish and servers skills declare their provider's MCP server for Codex in `agents/openai.yaml` (fal's MCP server; Cloudflare's docs server, which needs no sign-in), so Codex can wire it when the skill is used.
30
+ - The Homie mod holds a change to a studio's Cloudflare account made outside its deploy, inside the studio: Wrangler deleting something (a Worker, a D1 database, an R2 bucket or object, a KV namespace or key, a queue, a secret), a `secret put`, a version rolled out or back by hand, a migration or a writing query on the live database (`--remote`), and the same through Cloudflare's own MCP servers or a claude.ai Cloudflare connector (the API server's `execute` sending anything but a GET or a GraphQL read; a tool that deletes, updates or edits). The hold names the studio's own Worker, database or bucket when the change touches one. Creating something and reading are not held; `guardDeploys: false` turns it off.
31
+ - The mod's spending guard knows the providers' own CLIs and MCP servers: a generating command of `elevenlabs`, `fal api` or `fal run`, `genmedia run` and `tripo` is held as a call whose cost cannot be read first, and so is a run on fal's, ElevenLabs' or Tripo's MCP server. Their help, dry runs, sign-ins, prices, listings and ElevenLabs' `estimate_only` are free.
32
+
33
+ ### Changed
34
+
35
+ - The art and video skills find models and read prices on fal's own MCP server at its sign-in address (`https://mcp.fal.ai/mcp-relay`: the person signs in on fal's page, no key), with the commands that connect it in Claude Code and in Codex. Paid runs still go through the skills' scripts (priced, capped, receipted, resumable), never the MCP's `run_model`.
36
+ - The publish skill points at Cloudflare's own tools for questions beyond the deploy (its docs MCP server; its plugin's skills and API server, from `cloudflare/skills` or Claude's plugin directory; `wrangler login --device` where no browser can open) and says the studio's own Worker, database, storage and secrets change only through its deploy. The servers skill reads Cloudflare's model catalogue (`wrangler ai models list --json`) rather than memory.
37
+ - The music skill names ElevenLabs' plugin for Codex too, their skills on their own (`npx skills add elevenlabs/skills`) and their hosted MCP server, which has no music tools, and prefers the CLI to a raw API call.
38
+ - The models skill keeps Tripo on fal: Tripo's own CLI and MCP server bill a separate Tripo account outside the skill's budget and receipts.
39
+
40
+ ### Fixed
41
+
42
+ - The studio-setup and style skills' descriptions are plain YAML now. Each held a `: `, which a strict YAML reader refuses.
43
+
44
+ ## [0.24.0] - 2026-10-03
45
+
46
+ **Plugin 0.25.0** · [#34](https://github.com/homie-rocks/homie/pull/34) · [release-2026-10-03-studio-0.24.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.24.0)
47
+
48
+ Sell things in your games with your own Stripe account: a supporter pack, cosmetics, a pass, an unlock or a tip, in real money, with refunds from your office and the kids rules built in; Homie never sees the money.
49
+
50
+ ### Added
51
+
52
+ - The shop: `shop.json` at your studio's root lists what you sell (`cosmetic`, `supporter`, `pass`, `unlock`, `tip`), each in real money with the entitlement keys your game reads. `homie-studio shop init --supporter` writes a US$5 Supporter pack (a badge on the player's profile and beside their name in rooms, for a year) and `SELLING.md`, plain words on what selling makes you responsible for (refunds, disputes, tax, kids; not legal advice).
53
+ - Checkout is Stripe's own hosted page, opened by your studio's Worker with your own restricted key, one item at a time. Stripe Tax is on by default; Stripe Managed Payments (Stripe as the seller of record: it registers, files and pays the tax and answers disputes, for 3.5% more) is one switch, `"till": "stripe-managed"`. Stripe's receipt email is the receipt.
54
+ - A signed webhook (`/api/shop/hook`: Stripe's signature, within five minutes, each event once) is the only thing that marks an order paid, refunded or disputed. What a player owns is tied to their passkey account and follows them to every device.
55
+ - `homie-studio shop connect`: a page on your own computer where you paste the restricted key and the webhook secret. It says exactly what to make in Stripe, offers Managed Payments in plain words, and puts both straight into your Worker's secrets: never the chat, a file or a log. Test keys only unless `--live`.
56
+ - In a game: `createShop()` from `@homie-rocks/studio/shop` gives `shop.has('skin:ember')`, `shop.entitlements()`, `shop.on('change')`, `shop.open(item)` and `shop.used(key)`. The play page has a store sheet (from the game, or Shop in the room sheet) that opens Stripe in a new tab while the game keeps running; on a television it shows only a code to buy on a phone.
57
+ - A supporter's badge rides on their seat in every room (`peer.badge`, set by your Worker from what the account owns; a hello can never claim one), and their account page lists their purchases and badges.
58
+ - Refunds: one tap in your office (`/_studio/office/shop`), and a player's own refund of an unused item within your refund window (at least 14 days). Your AI can only ask for a refund (`homie-studio shop refund <order>`), and you confirm it with one tap.
59
+ - The office's shop page: the last 30 days, every order with its Stripe page, disputes, and payouts, balance and tax as links to your own Stripe Dashboard, plus a CSV for your accountant with no names in it.
60
+ - Referrals: a `?via=` link from another site (another studio, or homie.rocks on the same terms) is remembered on a new player's first visit; a kept sale owes that referrer the rate in `shop.json`. `homie-studio shop statements` signs each referrer's monthly statement with your studio's key; the referrer checks it against your manifest and invoices you. Nothing moves through Homie.
61
+ - New D1 migration `0008_studio_shop.sql`: orders, entitlements, the age band, parent links, webhook events and referral books. No card, no address, no email.
62
+ - The kids and fairness rules are built in, not settings. No shop on a kids server, and none at all in a studio whose `studio.json` says `"audience": "kids"`.
63
+ - Spending is off on every account until one neutral question (the year you were born, no default) says adult; the answer is kept only as adult, teen or child. Under 13: nothing, ever. 13 to 17: a one-time link a parent opens on their own phone and pays in their own name.
64
+ - Nothing random for money, no gems or coins, no countdown offers, and nothing that changes play on a beginner server (`"advantage": true` items are not sold or counted there). `shop check` and every build refuse a `shop.json` that breaks these. A monthly cap per player (US$50 at most).
65
+ - A card dispute never locks or deletes an account: nothing changes while it is open, and a lost one takes back only that item.
66
+
67
+ ### Changed
68
+
69
+ - Deleting a player account that owns things asks first, and keeps the orders without the player. A player's export includes their orders.
70
+ - The directory manifest says whether the shop is open and its till, and how this studio takes referral statements (with the public half of its statement key). Never a key or a sale.
71
+
72
+ ### Upgrade notes
73
+
74
+ - `homie-studio upgrade --apply`, then `npm run deploy` applies migration 0008. Nothing is sold until you add `shop.json` and connect a key.
20
75
  ## [0.23.0] - 2026-10-03
21
76
 
22
- **Plugin 0.24.0** · [#33](https://github.com/homie-rocks/homie/pull/33)
77
+ **Plugin 0.24.0** · [#33](https://github.com/homie-rocks/homie/pull/33) · [release-2026-10-03-studio-0.23.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-03-studio-0.23.0)
23
78
 
24
79
  Room chat in every game: reactions that float up every screen, quick lines, typing where the rules allow, speech bubbles over characters, rules per game and server, moderation in your own Worker, and the owner's tools.
25
80
 
package/README.md CHANGED
@@ -323,6 +323,28 @@ npx homie-studio chat words # the bu
323
323
  [chat/CHAT.md](chat/CHAT.md) is the short version and why Clef; [chat/OWNERS.md](chat/OWNERS.md) a plain note for
324
324
  studio owners on chat and children's data; NETPLAY.md section 19 the wire.
325
325
 
326
+ ## Selling things: the shop
327
+
328
+ A studio sells items for its games (0.24.0) with **its own Stripe account**: the studio is the seller, money goes
329
+ straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
330
+ currency; not a Connect platform). Real-money prices only, nothing random, the kids rules built in (no shop on a kids
331
+ server, spending off until a neutral age question says adult, nothing for under-13s, 13-17 through a parent's own
332
+ checkout, a monthly cap), and the television only shows a code to buy on a phone.
333
+
334
+ ```sh
335
+ npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
336
+ npx homie-studio shop check # the kit's rules; every build checks them too
337
+ npx homie-studio shop connect # a page on THIS computer: the owner pastes a restricted key and the webhook secret
338
+ npx homie-studio shop # open (test or live) or what is missing
339
+ npx homie-studio shop refund ord_… # an ASK: the owner taps once (or Refund in /_studio/office/shop)
340
+ ```
341
+
342
+ In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`); a
343
+ supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), Stripe Tax on, or Stripe Managed
344
+ Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
345
+ office; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
346
+ referrer). The guide: `shop/SHOP.md`.
347
+
326
348
  ## Setup status
327
349
 
328
350
  ```sh
@@ -121,6 +121,16 @@
121
121
  * time AI guides may talk are ASKED for, like office kick; --budget is
122
122
  * Workers AI neurons a day, or dollars a day for the owner's key)
123
123
  * homie-studio agents brain key [--remove] (the owner's AI key, typed into a page on this computer only)
124
+ * homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
125
+ * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
126
+ * homie-studio shop check shop.json against the kit's rules (real money, nothing random, the kids rules)
127
+ * homie-studio shop connect [--managed] [--live] a page on THIS computer where the owner pastes the studio's
128
+ * restricted Stripe key and webhook secret: straight to the Worker secrets,
129
+ * never a chat or a file (test keys only unless --live)
130
+ * homie-studio shop disconnect both secrets gone: the shop closes
131
+ * homie-studio shop orders the latest orders (never a card or an email)
132
+ * homie-studio shop refund <order> [--reason …] [--note "<why>"] ASKS the owner (a one-tap link)
133
+ * homie-studio shop statements [--period YYYY-MM] [--send] signed referral statements this studio owes
124
134
  * homie-studio agents sit <game> [--server <id>] [--pass hap_…] [--label Claude]
125
135
  * (an AI guide's seat from this terminal: then lines of `do <goal> {args}`,
126
136
  * `say <line> {args}`, `look`, `stand`)
@@ -226,6 +236,7 @@ import { officeAnnounce, officeClose, officeInvite, officeKey, officeKick, offic
226
236
  import { chatBudget, chatLines, chatRemove, chatRulesSet, chatShow, chatWords } from '../lib/chat-cli.mjs';
227
237
  import { agentsBrain, agentsBrainKey, agentsPass, agentsPasses, agentsRevoke, serversClose, serversLevel, serversLines, serversList, serversMember, serversNew, serversSet } from '../lib/servers.mjs';
228
238
  import { AgentSeat } from '../lib/agent-seat.mjs';
239
+ import { shopCheck, shopConnect, shopDisconnect, shopInit, shopLines, shopOrders, shopRefund, shopStatements, shopStatus } from '../lib/shop.mjs';
229
240
  import { Feed, currentFeed, currentId, flushProgress, publicFeed, readFeed, recordChange, startProgress } from '../lib/progress.mjs';
230
241
  import { formatStatus, setupStatus } from '../lib/doctor.mjs';
231
242
  import { codexTarget, newCodex, writeCodexPage } from '../lib/codex.mjs';
@@ -238,7 +249,7 @@ import { formatHandoff, handoff } from '../lib/handoff.mjs';
238
249
 
239
250
  const argv = process.argv.slice(2);
240
251
  const flags = new Map();
241
- const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged'];
252
+ const BOOL_FLAGS = ['off', 'revoke', 'json', 'yes', 'detach', 'no-install', 'plan', 'stop', 'share', 'apply', 'diff', 'template', 'ci', 'fresh', 'install', 'remove', 'replace', 'artifact', 'open', 'reopen', 'kids', 'remote-ai', 'dry-run', 'verify', 'maps', 'profile', 'hands-on', 'automatic', 'unlock', 'confirm', 'no-library', 'no-validate', 'rigged', 'supporter', 'managed', 'live', 'send'];
242
253
  const positional = [];
243
254
  for (let i = 0; i < argv.length; i++) {
244
255
  const a = argv[i];
@@ -441,8 +452,18 @@ function print(result) {
441
452
  case 'chat remove':
442
453
  lines.push(result.message);
443
454
  break;
455
+ case 'shop':
456
+ case 'shop orders':
457
+ case 'shop check':
458
+ case 'shop init':
459
+ case 'shop statements':
460
+ case 'shop connect':
461
+ case 'shop disconnect':
462
+ lines.push(...shopLines(result));
463
+ break;
444
464
  case 'chat rules':
445
465
  case 'chat budget':
466
+ case 'shop refund':
446
467
  lines.push(result.asked ? `Asked: ${result.what}` : result.message, ...(result.asked ? [`Owner's one-tap link (until the ask ends, 15 min): ${result.link}`, result.use] : []));
447
468
  break;
448
469
  case 'servers new':
@@ -747,6 +768,18 @@ async function main() {
747
768
  if (sub === 'member') return serversMember(root, positional[2], positional[3], positional[4], { url, role: flags.get('role'), remove: flags.has('remove') });
748
769
  return { ok: false, command: 'servers', why: `unknown: servers ${sub} (list, new, set, close, level, member)` };
749
770
  }
771
+ if (cmd === 'shop') {
772
+ const url = flags.get('url');
773
+ if (!sub) return shopStatus(root, { url });
774
+ if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), currency: flags.get('currency') ?? 'usd', price: flags.get('price') ?? 500, managed: flags.has('managed') });
775
+ if (sub === 'check') return shopCheck(root);
776
+ if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), log });
777
+ if (sub === 'disconnect') return shopDisconnect(root);
778
+ if (sub === 'orders') return shopOrders(root, { url });
779
+ if (sub === 'refund') return shopRefund(root, positional[2], { url, reason: flags.get('reason'), note: flags.get('note') });
780
+ if (sub === 'statements') return shopStatements(root, { url, period: flags.get('period'), send: flags.has('send') });
781
+ return { ok: false, command: 'shop', why: `unknown: shop ${sub} (init, check, connect, disconnect, orders, refund, statements)` };
782
+ }
750
783
  if (cmd === 'agents') {
751
784
  const url = flags.get('url');
752
785
  if (sub === 'pass') return agentsPass(root, positional[2], { url, label: flags.get('label'), server: flags.get('server'), hands: flags.get('hands'), role: flags.get('role'), days: flags.get('days') });
package/lib/build.mjs CHANGED
@@ -9,9 +9,10 @@
9
9
  * (games/<id>/assets/manifest.json; lib/asset-manifest.mjs servedAssets)
10
10
  * site/dist/games/<id>/agents.json the AI guides' vocabulary (games/<id>/agents.json, checked; NETPLAY.md
11
11
  * section 18): the only goals and lines an AI in its rooms has
12
- * site/dist/games.json { studio, games[], songs[], videos[], posts[], site } from studio.json,
12
+ * site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
13
13
  * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
14
- * posts/*.md and the studio's site/ folder (site/SITE.md)
14
+ * posts/*.md, the studio's site/ folder (site/SITE.md) and shop.json (checked
15
+ * against the kit's rules: a shop that breaks them stops the build)
15
16
  * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
16
17
  * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
17
18
  * site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
@@ -47,6 +48,8 @@ import { licenseOf, remixRow } from '../worker/license.mjs';
47
48
  import { SERVER_LIMITS, serverOf } from '../worker/servers.mjs';
48
49
  import { chatProblems } from '../worker/chat.mjs';
49
50
  import { vocabularyOf } from '../worker/brain.mjs';
51
+ import { shopForBuild } from './shop.mjs';
52
+ import { audienceOf } from '../worker/shop-rules.mjs';
50
53
 
51
54
  const SOURCE_SKIP = new Set(['node_modules', 'dist', '.git', '.wrangler', '.port']);
52
55
  /** Never copied into a static game's served folder. */
@@ -300,6 +303,8 @@ export async function buildGameFiles(esbuild, root, g, out, { maps = false, sour
300
303
  export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false } = {}) {
301
304
  const esbuild = await studioEsbuild(root);
302
305
  const studio = readStudio(root);
306
+ // The shop first (shop/SHOP.md): a shop.json that breaks the kit's rules stops the build before anything is built.
307
+ const shop = shopForBuild(root, { log });
303
308
  const dist = join(root, 'site', 'dist');
304
309
  const games = listGames(root).filter((g) => !only || g.id === only);
305
310
  if (only && !games.length) throw new Error(`no game "${only}" in games/`);
@@ -402,6 +407,10 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
402
407
  : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
403
408
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
404
409
  ...(studio.template === true ? { template: true } : {}),
410
+ // studio.json "audience": "kids" (a studio made for children: no shop at all) or "teens"; and "referrals": false
411
+ // (this studio takes no referral statements as a referrer).
412
+ ...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
413
+ ...(studio.referrals === false ? { referrals: false } : {}),
405
414
  build: buildInfo(root),
406
415
  },
407
416
  games: rows,
@@ -410,6 +419,8 @@ export async function build(root, { only = null, log = () => {}, deploy = proces
410
419
  // Summaries only; each post's HTML is in _site/posts.json.
411
420
  posts: posts.map(({ html, record, ...p }) => p),
412
421
  site: { pages: site.pages, partials: site.partials, ...(site.css ? { css: site.css } : {}) },
422
+ // shop.json, checked (nothing secret is ever in it): the Worker sells from this and checks it again.
423
+ ...(shop ? { shop } : {}),
413
424
  };
414
425
  writeFileSync(join(dist, 'games.json'), `${JSON.stringify(catalogue, null, 2)}\n`);
415
426
  return {
package/lib/scaffold.mjs CHANGED
@@ -29,6 +29,7 @@ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs
29
29
  import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
30
30
  import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
31
31
  import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
32
+ import { SHOP_MIGRATION, SHOP_MIGRATION_FILE } from '../worker/shop-store.mjs';
32
33
  import { themeFile } from './site.mjs';
33
34
 
34
35
  export const COMPAT_DATE = '2026-06-01';
@@ -342,6 +343,37 @@ studio's pinned copy, never a registry lookup of the bare name.
342
343
  budget, or between decisions, the game's \`decide\` plays. \`agent_sit\` (the local MCP) puts the owner's own
343
344
  Claude in a guide's seat.
344
345
 
346
+ ## Selling things (the shop)
347
+
348
+ - **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
349
+ disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
350
+ \`SELLING.md\` says what that means for the owner in plain words (not legal advice).
351
+ - **\`shop.json\`** (at the studio's root, reviewed in git): \`till\` (\`stripe\`, the studio is the seller with Stripe
352
+ Tax on; \`stripe-managed\`, Stripe Managed Payments is the seller of record and files the tax for 3.5% more; or
353
+ \`off\`), \`currency\`, \`refundDays\` (at least 14), \`capPerPlayerMonth\` (cents, at most 5000) and \`items\`:
354
+ \`{ "id", "kind": "cosmetic"|"supporter"|"pass"|"unlock"|"tip", "name", "price": <cents>, "gives": ["skin:ember"],
355
+ "days"?, "game"?, "advantage"? }\`. Real money only: no gems, coins or points. \`npx --no-install homie-studio shop
356
+ init --supporter\` writes a US$5 Supporter pack; \`shop check\` and every build refuse what the kit refuses.
357
+ - **The kids rules are the kit's, not the studio's to switch off:** nothing random for money (an item that names
358
+ chance, odds, a crate, a box or a mystery is refused), no countdown offers, no shop on a kids server or in a studio
359
+ with \`"audience": "kids"\` in studio.json, spending off on every account until a neutral age question says adult,
360
+ nothing ever for under-13s, 13-17 only through a parent's own checkout (a one-time link), nothing with
361
+ \`"advantage": true\` on a beginner server, one hosted Stripe checkout per purchase, a monthly cap. The television
362
+ never sells: its store sheet is a code to buy on a phone.
363
+ - **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
364
+ \`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
365
+ play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
366
+ (\`peer.badge\`, set by the Worker, never by a hello). The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
367
+ - **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
368
+ prints a 127.0.0.1 link; the owner pastes a restricted key and the webhook secret there, and they go straight to
369
+ the Worker secrets. Test keys only unless \`--live\`. Never ask for a key in the chat; never write one anywhere.
370
+ - **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
371
+ \`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
372
+ account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
373
+ - **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
374
+ player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
375
+ signs them; the referrer invoices the studio. Nothing moves through Homie.
376
+
345
377
  ## Continuing a build from the Claude app
346
378
 
347
379
  A Claude Code session started from the Claude app's card gets one short line, like
@@ -573,6 +605,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
573
605
  [`site/migrations/${OFFICE_MIGRATION_FILE}`]: OFFICE_MIGRATION,
574
606
  [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
575
607
  [`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
608
+ [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
576
609
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
577
610
  '.claude/skills/.gitkeep': '',
578
611
  };
@@ -664,6 +697,19 @@ export function ensureServersMigration(root) {
664
697
  return `site/migrations/${SERVERS_MIGRATION_FILE}`;
665
698
  }
666
699
 
700
+ /**
701
+ * A studio made before 0.24.0 has no shop migration (worker/shop-store.mjs: orders, entitlements, the age band,
702
+ * referral lines): add it, so the next `d1 migrations apply` makes its tables. A studio with no shop.json keeps
703
+ * them empty. Returns the file it wrote, or null when it was there.
704
+ */
705
+ export function ensureShopMigration(root) {
706
+ const file = join(root, 'site', 'migrations', SHOP_MIGRATION_FILE);
707
+ if (existsSync(file)) return null;
708
+ mkdirSync(dirname(file), { recursive: true });
709
+ writeFileSync(file, SHOP_MIGRATION);
710
+ return `site/migrations/${SHOP_MIGRATION_FILE}`;
711
+ }
712
+
667
713
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
668
714
  /** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
669
715
  function ensureChatMigration(root) {
@@ -675,12 +721,12 @@ function ensureChatMigration(root) {
675
721
  }
676
722
 
677
723
  export function ensureMigrations(root) {
678
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root)].filter(Boolean);
724
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root)].filter(Boolean);
679
725
  }
680
726
 
681
727
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
682
728
  export function migrationWord(file) {
683
- return /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
729
+ return /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (it sells nothing until shop.json and the owner\'s Stripe key are in)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
684
730
  }
685
731
 
686
732
  /**
package/lib/shop.mjs ADDED
@@ -0,0 +1,270 @@
1
+ /**
2
+ * `homie-studio shop` — a studio's shop from the studio folder (@homie-rocks/studio 0.24.0; worker/shop.mjs has the
3
+ * rules, shop/SHOP.md the guide, shop/SELLING.md the owner's plain words).
4
+ *
5
+ * homie-studio shop is it selling, and if not, what is missing (the live site's office view)
6
+ * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
7
+ * writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
8
+ * homie-studio shop check shop.json against the kit's rules (no randomness, real money, the cap…)
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).
15
+ * homie-studio shop disconnect removes both secrets (the shop closes at once)
16
+ * homie-studio shop orders the latest orders (never a card or an email)
17
+ * homie-studio shop refund <order> [--reason requested_by_customer|duplicate|fraudulent] [--note "<why>"]
18
+ * ASKS the owner (a one-tap link): the AI never refunds by itself
19
+ * homie-studio shop statements [--period YYYY-MM] [--send] the signed referral statements this studio owes
20
+ *
21
+ * The proof of ownership is the studio's own Cloudflare login, as for the office.
22
+ */
23
+ import { createServer } from 'node:http';
24
+ import { randomBytes } from 'node:crypto';
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { dirname, join } from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+ import { askedFor, withKey } from './office.mjs';
29
+ import { runner } from './cloudflare.mjs';
30
+ 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';
33
+
34
+ const HERE = dirname(fileURLToPath(import.meta.url));
35
+ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
36
+ /** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
37
+ export const HOOK_EVENTS = Object.freeze([
38
+ 'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
39
+ 'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
40
+ ]);
41
+ /** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
42
+ export const KEY_PERMISSIONS = Object.freeze([
43
+ ['Checkout Sessions', 'Write', 'open Stripe\'s checkout page for one item'],
44
+ ['Charges', 'Write', 'refunds (Stripe keeps refunds under Charges)'],
45
+ ['PaymentIntents', 'Read', 'which payment an order was'],
46
+ ['Disputes', 'Read', 'see a dispute; you answer it in Stripe'],
47
+ ]);
48
+
49
+ /** shop.json read and checked with the studio's games and audience: { ok, shop, errors, warnings, file }. */
50
+ export function readShop(root) {
51
+ const file = join(root, SHOP_FILE);
52
+ if (!existsSync(file)) return { ok: true, shop: null, errors: [], warnings: [], file, absent: true };
53
+ let raw = null;
54
+ try { raw = JSON.parse(readFileSync(file, 'utf8')); } catch (error) { return { ok: false, shop: null, errors: [{ at: '', message: `shop.json is not JSON: ${error.message}` }], warnings: [], file }; }
55
+ const games = listGames(root).map((g) => g.id);
56
+ const studio = readStudio(root);
57
+ return { ...checkShop(raw, { games, audience: audienceOf(studio) }), file };
58
+ }
59
+
60
+ /** The build's half: the checked shop for games.json, or a thrown error that says what to fix (nothing is published). */
61
+ export function shopForBuild(root, { log = () => {} } = {}) {
62
+ const r = readShop(root);
63
+ if (r.absent) return null;
64
+ if (!r.ok) throw new Error(`shop.json breaks the shop's rules, so the build stops (nothing is sold that the kit refuses):\n${r.errors.map((e) => ` ${e.at || 'shop.json'}: ${e.message}`).join('\n')}`);
65
+ for (const w of r.warnings) log(`shop.json ${w.at}: ${w.message}`);
66
+ return r.shop;
67
+ }
68
+
69
+ export function shopCheck(root) {
70
+ const r = readShop(root);
71
+ 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).' };
72
+ return { ok: r.ok, command: 'shop check', 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('; ')}` };
73
+ }
74
+
75
+ /** The Supporter pack the kit suggests first: deterministic, never pay-to-win, a badge on the profile and in rooms. */
76
+ export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
77
+ 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.' };
78
+ }
79
+
80
+ export function shopInit(root, { supporter = false, currency = 'usd', price = 500, managed = false } = {}) {
81
+ const file = join(root, SHOP_FILE);
82
+ if (existsSync(file)) return { ok: false, command: 'shop init', why: 'shop.json is here already: change it, then homie-studio shop check' };
83
+ const studio = readStudio(root);
84
+ if (audienceOf(studio) === 'kids') return { ok: false, command: 'shop init', why: 'studio.json says "audience": "kids": a studio made for children sells nothing in its games' };
85
+ const shop = {
86
+ till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(), refundDays: 14, capPerPlayerMonth: 5000,
87
+ items: supporter ? [supporterItem({ price: Math.floor(Number(price)) || 500 })] : [],
88
+ referrals: { rate: 0.1, windowDays: 30, capPerPlayer: 1000, holdDays: 30, minimumInvoice: 2500, accept: ['stripe-invoice'] },
89
+ };
90
+ const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
91
+ if (!r.ok) return { ok: false, command: 'shop init', why: r.errors.map((e) => e.message).join('; ') };
92
+ writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
93
+ const wrote = [SHOP_FILE];
94
+ if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
95
+ 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)'] };
96
+ }
97
+
98
+ /** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
99
+ export async function shopStatus(root, { url } = {}) {
100
+ const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
101
+ if (!r.ok) return { ok: false, command: 'shop', why: r.message ?? r.why ?? 'the studio did not answer' };
102
+ const local = readShop(root);
103
+ 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 } };
104
+ }
105
+
106
+ export async function shopOrders(root, { url } = {}) {
107
+ const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
108
+ if (!r.ok) return { ok: false, command: 'shop orders', why: r.message ?? r.why ?? 'the studio did not answer' };
109
+ 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 })) };
110
+ }
111
+
112
+ /** The AI proposes a refund: an ask the owner confirms with one tap (an office key can never refund by itself). */
113
+ export async function shopRefund(root, order, { url, reason, note } = {}) {
114
+ 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)' };
115
+ const r = await withKey(root, url, (call) => call('/_studio/api/shop/refund', { order, ...(reason ? { reason } : {}), ...(note ? { note } : {}) }));
116
+ return askedFor(root, url, r, 'shop refund');
117
+ }
118
+
119
+ export async function shopStatements(root, { url, period, send = false } = {}) {
120
+ const r = await withKey(root, url, (call) => (send ? call('/_studio/api/shop/statements/send', { ...(period ? { period } : {}) }) : call(`/_studio/api/shop/statements${period ? `?period=${encodeURIComponent(period)}` : ''}`)));
121
+ if (!r.ok) return { ok: false, command: 'shop statements', why: r.message ?? r.why ?? 'the studio did not answer' };
122
+ return { ok: true, command: 'shop statements', period: r.period, ...(send ? { sent: r.sent } : { statements: r.statements }) };
123
+ }
124
+
125
+ /* ------------------------------------------------------------------ the key, from a page on this computer */
126
+
127
+ const esc = (v) => String(v ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
128
+
129
+ /** The one page the owner uses: what to make in Stripe, what Managed Payments costs and does, two fields, one button. */
130
+ export function connectPage({ nonce, site, studio, till = 'stripe', test = true }) {
131
+ const dash = `https://dashboard.stripe.com${test ? '/test' : ''}`;
132
+ const perms = KEY_PERMISSIONS.map(([n, level, why]) => `<li><b>${esc(n)}</b>: ${esc(level)} <small>(${esc(why)})</small></li>`).join('');
133
+ const events = HOOK_EVENTS.map((e) => `<code>${esc(e)}</code>`).join(', ');
134
+ 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>
136
+ <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>
141
+ <li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
142
+ <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>
148
+ <form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
149
+ <h2>3. Paste both here</h2>
150
+ <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>
152
+ <h2>4. Who is the seller?</h2>
153
+ <fieldset>
154
+ <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>
156
+ </fieldset>
157
+ <button>Save to my Worker</button></form>
158
+ <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
+ }
160
+
161
+ /**
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).
165
+ */
166
+ export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
167
+ const studio = readStudio(root);
168
+ const site = siteUrl(root) ?? 'https://<your studio>';
169
+ const env = studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {};
170
+ const w = runner(root, env);
171
+ const shopNow = readShop(root);
172
+ const till = managed === true ? 'stripe-managed' : managed === false ? 'stripe' : shopNow.shop?.till ?? 'stripe';
173
+ const nonce = randomBytes(16).toString('hex');
174
+ const page = connectPage({ nonce, site, studio: studio.name ?? 'Your studio', till, test: !live });
175
+ return await new Promise((done) => {
176
+ let finished = false;
177
+ const server = createServer((req, res) => {
178
+ 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
+ if (req.method === 'POST' && req.url === '/key') {
180
+ let body = '';
181
+ req.on('data', (c) => { body += c; if (body.length > 8192) req.destroy(); });
182
+ req.on('end', async () => {
183
+ const form = new URLSearchParams(body);
184
+ const key = String(form.get('key') ?? '').trim();
185
+ const hook = String(form.get('hook') ?? '').trim();
186
+ 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; }
191
+ // 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)' });
222
+ });
223
+ return;
224
+ }
225
+ res.writeHead(404); res.end();
226
+ });
227
+ server.listen(port, '127.0.0.1', () => {
228
+ const link = `http://127.0.0.1:${server.address().port}/${nonce}`;
229
+ log(`Open this page on this computer; the owner pastes the key there (never in a chat): ${link}`);
230
+ });
231
+ 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?.();
232
+ });
233
+ }
234
+
235
+ export function shopDisconnect(root) {
236
+ const studio = readStudio(root);
237
+ const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
238
+ const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
239
+ const b = w(['secret', 'delete', 'STRIPE_WEBHOOK_SECRET'], { input: 'y\n' });
240
+ 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(' ') };
241
+ }
242
+
243
+ /** Lines for a person (the CLI's print). */
244
+ export function shopLines(r) {
245
+ const lines = [];
246
+ if (r.command === 'shop') {
247
+ 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:');
248
+ for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
249
+ 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.`);
250
+ lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
251
+ if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
252
+ } else if (r.command === 'shop orders') {
253
+ for (const o of r.orders) lines.push(`${o.id} ${o.at.slice(0, 16)} ${o.name} ${o.shown} ${o.status}${o.mode === 'test' ? ' (test)' : ''} ${o.player ?? '(deleted account)'}${o.via ? ` via ${o.via}` : ''}`);
254
+ if (!r.orders.length) lines.push('No orders yet.');
255
+ } else if (r.command === 'shop check') {
256
+ if (r.absent) lines.push(r.message);
257
+ else {
258
+ lines.push(r.ok ? `shop.json follows the rules: ${r.items.join(', ') || 'no items yet'} (till: ${r.till}).` : 'shop.json breaks the rules:');
259
+ for (const e of r.errors ?? []) lines.push(` ${e.at}: ${e.message}`);
260
+ for (const w of r.warnings ?? []) lines.push(` note ${w.at}: ${w.message}`);
261
+ }
262
+ } else if (r.command === 'shop init') {
263
+ lines.push(`Wrote ${r.wrote.join(', ')} (till: ${r.till}${r.items.length ? `; ${r.items.join(', ')}` : ''}). Next:`, ...r.next.map((n) => ` ${n}`));
264
+ } else if (r.command === 'shop statements') {
265
+ if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
266
+ else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${money(s.statement.totals.due, s.statement.currency)} (${s.statement.lines.length} line(s)), signed`);
267
+ if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
268
+ } else lines.push(r.message ?? '');
269
+ return lines;
270
+ }