@homie-rocks/studio 0.33.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,9 +17,61 @@ 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.35.0] - 2026-10-08
21
+
22
+ **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67)
23
+
24
+ A shop with no Stripe key to find. You approve once in your browser, and your AI sets up the rest in your own Stripe account.
25
+
26
+ ### Added
27
+
28
+ - New shops use Stripe Payment Links by default: one official CLI browser approval when needed, no human-handled key and no API key in the Worker. Connect syncs Products, fixed/custom-amount Prices, links and the webhook from shop.json; edits replace versions and removals archive them.
29
+ - Signed events fulfill keyless purchases and chosen-amount tips, book refunds and disputes even when delivery is reordered, and reject forged events. Free orders remain local. Office refunds point to Stripe or the owner's connected AI; missing webhooks wait for Stripe redelivery.
30
+ - Keep the existing keyed checkout for dynamic carts, spending reservations, custom expiry, provider-read recovery and office refunds. Readiness explains the next step; skills, guide, scaffold and MCP instructions follow the keyless default. No Homie service enters the money path.
31
+
32
+ ## [0.34.0] - 2026-10-08
33
+
34
+ **Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64) · [release-2026-10-08-studio-0.34.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.34.0)
35
+
36
+ A plain shop: nothing is imposed unless you write it. A shop is open to guests by default, the protective rules 0.33.0 started with are a preset you choose, and a player can put several items in a cart and pay once.
37
+
38
+ ### Added
39
+
40
+ - **A cart.** A cart holds several items and quantities, free and paid lines together, and is paid in one Stripe Checkout Session. `shop.add(item, quantity, amount?)` and `shop.checkout()` build and pay for a cart; `shop.buy(item)` still buys one thing directly. Each line keeps the item as it was defined at the time of sale, grants exactly once, and can be refunded on its own in the office.
41
+ - A cart with only free lines grants its items without going to Stripe.
42
+ - `checkoutMinutes` in `shop.json` sets how long a checkout stays open, within Stripe's bounds of 30 to 1440 minutes.
43
+ - The shop accepts a restricted (`rk_`) or a full secret (`sk_`) Stripe key. A preview you give its own database and both Stripe secrets can sell; previews normally have no database.
44
+
45
+ ### Changed
46
+
47
+ - **Your shop is open by default.** 0.33.0 started every shop on the protective preset. Now a shop that names no preset has no policy: guests can buy, and buy again, without an age question or a parent step, from any page. You choose your items, wording, kinds, prices, dates, caps, refund windows and referral terms. `shop init` writes no policy.
48
+ - **Every policy is optional.** The account, age, kids-server, advantage, repeat-purchase, used-item refund and TV rules apply only when you write them, one by one or with `"preset": "protective"` or `"preset": "adults-only"`. Stripe Tax and the withdrawal acknowledgement are optional too. The toolkit no longer scans your items' wording in any preset. Flood protection stays, with rates you can change.
49
+ - **A player cannot refund a tip.** A refund window you set covers items, including used ones unless you choose otherwise. You can refund any order or any line from your office.
50
+ - A checkout now stays open for Stripe's default of 24 hours instead of 31 minutes.
51
+ - Shop and owned-item pages now ask Stripe about unfinished payments in the background (up to three at a time), instead of only when a purchase was refused by your spending cap. A paid order nobody came back to is found after a minute, with or without a cap, and by your Worker's scheduled handler if you set one.
52
+ - With a spending cap, a refund of one line gives back that line's amount. Before, a partial refund kept the whole price counted.
53
+
54
+ ### Fixed
55
+
56
+ - **The webhook signing secret is required.** The shop is ready only with both the Stripe key and the webhook signing secret, whichever kind of key you use, and the shop's setup steps and readiness name whichever is missing. Payment is confirmed by Stripe's signed event, not only by the buyer's return to your site.
57
+ - Cancelling or replacing a checkout asks Stripe to expire the open session before freeing its place under your spending cap. A checkout with no Stripe session frees its place after the checkout lifetime plus one minute.
58
+ - Refunds follow Stripe's own list of refunds and their statuses. A refund that names a line affects only that line, a pending refund keeps the item, and a failed refund gives it back. A partial refund made in the Stripe Dashboard lowers the order's balance, cap and referral totals without guessing a line. A refund of the whole order takes back the whole order, and a refund of a paid line leaves free lines in the same order alone. A refund event that arrives before its payment was recorded records the payment first.
59
+ - An order in dispute cannot be refunded until Stripe resolves the dispute, and a lost dispute keeps the refunds made before it. Every recorded payment is checked against Stripe's refunds and dispute state.
60
+ - A payment, its items and its referral share are recorded together, including a verified payment that arrives late. A paid order that is missing an item is repaired once.
61
+ - Shop errors no longer show Stripe keys or secrets. Deleting an account clears the checkout's buyer too. An older order whose item name is missing falls back to the catalog; an item with no definition gives an answer that can be retried and a note in the office.
62
+ - Checkout from inside a game keeps the running game in its tab and explains a blocked tab or blocked cookies. When a new tab cannot open, game and TV checkout use the current tab and return to the game or TV page.
63
+ - Signed Stripe events that are not about your shop are ignored. Optional cron triggers survive a deploy.
64
+
65
+ ### Upgrade notes
66
+
67
+ - **To keep the 0.33.0 behaviour, write one line.** A `shop.json` that names no preset was protective in 0.33.0 and is open in 0.34.0. To keep the account and age rules you had, write `"policy": { "preset": "protective" }` in `shop.json`. Settings you wrote yourself stay as written. The wording scans are gone in every preset.
68
+ - To keep Stripe Tax, write `"automaticTax": true`. To count referrals for new players only, write `"referralNewPlayersOnly": true`. To keep the shorter checkout, write `"checkoutMinutes": 31`.
69
+ - **Deploy applies one new database step, `0012_shop_lines.sql`.** It only adds: your orders become one-line orders, and nothing is removed, so the 0.33.0 Worker keeps working while you deploy and if you roll back. Until the step has run, owned items and office refunds work and checkout says which step is missing.
70
+ - **The webhook signing secret is required, with either kind of key.** A shop with a key and no signing secret reports not ready and does not sell. `homie-studio shop connect` makes the webhook and stores the secret, or takes one you made yourself.
71
+
20
72
  ## [0.33.1] - 2026-10-08
21
73
 
22
- **Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65)
74
+ **Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65) · [release-2026-10-08-studio-0.33.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.33.1)
23
75
 
24
76
  A fix for studios upgrading to 0.33.0, found by upgrading one of Homie's own.
25
77
 
package/README.md CHANGED
@@ -452,31 +452,35 @@ npx homie-studio lounge mod <player id> # a mode
452
452
  A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
453
453
  straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
454
454
  currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
455
- settings, with no Homie ceiling. New shops start with the editable `protective` policy: accounts and an age
456
- question, no purchases for under-13s, parent checkout for teens, no shop on kids servers, and no paid randomness.
457
- The studio can inspect and change each rule in shop.json; [shop/SHOP.md](shop/SHOP.md) explains every preset.
455
+ settings, with no Homie ceiling. The default is open: guests can buy repeatedly without an age question,
456
+ from any page, with any wording. `protective` and `adults-only` are optional presets; neither scans content.
457
+ The studio chooses its settings in shop.json; [shop/SHOP.md](shop/SHOP.md) explains them.
458
458
 
459
459
  ```sh
460
460
  npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
461
461
  npx homie-studio shop check # the studio settings and provider requirements; every build checks them too
462
462
  npx homie-studio shop catalog [--have <file>] # the items as Products in Stripe: the read, then the exact writes (Stripe's MCP)
463
- npx homie-studio shop connect # a page on THIS computer: the owner pastes one restricted key; it makes the webhook
463
+ npx homie-studio shop connect # Stripe browser approval; reports any remaining Worker credential step
464
464
  npx homie-studio shop # open (test or live) or what is missing
465
465
  npx homie-studio shop refund ord_… # an ASK: the owner taps once (or Refund in /_studio/office/shop)
466
466
  ```
467
467
 
468
- Since 0.24.3 the owner's AI sets it up with **Stripe's own agent tools** (`npm install -g @stripe/cli@latest && stripe
469
- agent setup`: Stripe's MCP server and skills; the owner signs in once on Stripe's page, a sandbox first): it makes the
470
- catalog through the MCP (`shop catalog` says exactly what to write), reads the tax settings, and answers "how are
471
- sales?" read-only. The owner makes the account, approves Stripe's pages and pastes **one** restricted key on the
472
- connect page, which makes the webhook with it, so its signing secret goes straight to the Worker and never through a
473
- chat. A webhook is never made through Stripe's MCP (its answer carries the secret). From 2026-10-31 Stripe's MCP takes
474
- only OAuth or Agent-tagged keys; the shop's own key is a plain restricted key and is not affected.
468
+ Connect with `stripe_login` or `shop connect`: the owner approves Stripe's official browser page, sandbox first.
469
+ Where the CLI supplies a transferable legacy test key, the toolkit creates the webhook and installs both Worker
470
+ secrets privately. It reports the 90-day expiry; `shop connect --renew` obtains approval again. Current CLI OAuth
471
+ and live mode cannot export an independent Worker's key, so the result names that limitation. Only if the owner
472
+ chooses the fallback does `shop connect --manual` open the old local key page (`--manual --live` for live mode).
473
+ Never request a key in chat. Login is not a verified purchase; complete the test purchase, webhook and refund check.
475
474
 
476
- In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`); a
477
- supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), Stripe Tax on, or Stripe Managed
475
+ Stripe's optional agent tools (`stripe agent setup`) provide MCP and skills for catalog work (`shop catalog`),
476
+ tax reads and sales questions. They are separate from the Worker's credentials. Never create a webhook through
477
+ MCP because its response contains a secret. From 2026-10-31 Stripe MCP accepts only OAuth or Agent-tagged keys;
478
+ that change does not affect the shop's ordinary restricted API key. [SHOP.md](shop/SHOP.md) describes the flow.
479
+
480
+ In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`, `add`, `checkout`, `buy`); a
481
+ supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), optional Stripe Tax, or Stripe Managed
478
482
  Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
479
- office; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
483
+ office, per order or line; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
480
484
  referrer). The guide: `shop/SHOP.md`.
481
485
 
482
486
  ## Setup status
@@ -170,7 +170,7 @@
170
170
  * homie-studio shop is the studio's shop selling (its own Stripe), and if not, what is missing
171
171
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed] shop.json and SELLING.md
172
172
  * homie-studio shop check shop.json against studio settings and provider requirements
173
- * homie-studio shop connect [--managed] [--live] a page on THIS computer where the owner pastes the studio's
173
+ * homie-studio shop connect [--live] [--renew] [--manual] sync Payment Links and webhook through Stripe browser approval; --manual chooses the local page for the studio's
174
174
  * restricted Stripe key; with it this makes the webhook (0.24.3), and the key
175
175
  * and the webhook's secret go straight to the Worker secrets, never a chat or a
176
176
  * file (test keys only unless --live)
@@ -368,7 +368,7 @@ import { standaloneDeployNotes } from '../lib/standalone.mjs';
368
368
 
369
369
  const argv = process.argv.slice(2);
370
370
  const flags = new Map();
371
- 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', 'no-rig', 'supporter', 'managed', 'live', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
371
+ 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', 'no-rig', 'supporter', 'managed', 'live', 'manual', 'renew', 'send', 'accept-tos', 'quiet', 'no-local-ai', 'timestamps', 'overwrite', 'own-route', 'before', 'release', 'device'];
372
372
  const positional = [];
373
373
  for (let i = 0; i < argv.length; i++) {
374
374
  const a = argv[i];
@@ -810,7 +810,7 @@ const OUTWARD_FLAGS = {
810
810
  'lounge mod': ['url', 'remove'], 'lounge remove': ['url', 'all'],
811
811
  'chat remove': ['url', 'all'], 'chat budget': ['url'],
812
812
  'servers close': ['url', 'reopen'], 'servers level': ['url'], 'servers member': ['url', 'role', 'remove'],
813
- 'shop connect': ['url', 'managed', 'live'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send', 'cursor'],
813
+ 'shop connect': ['url', 'managed', 'live', 'manual', 'renew'], 'shop disconnect': ['url'], 'shop refund': ['url', 'reason', 'note'], 'shop statements': ['url', 'period', 'send', 'cursor'],
814
814
  'agents pass': ['url', 'label', 'server', 'hands', 'role', 'days'], 'agents revoke': ['url'], 'agents brain': ['url', 'budget', 'remove'],
815
815
  'agents sit': ['url', 'server', 'pass', 'label', 'brain'],
816
816
  // With --device it adds a phone to the person's Apple team and installs on it: a flag it does not know stops it.
@@ -1039,7 +1039,7 @@ async function main() {
1039
1039
  if (!sub) return shopStatus(root, { url });
1040
1040
  if (sub === 'init') return shopInit(root, { supporter: flags.has('supporter'), currency: flags.get('currency') ?? 'usd', price: flags.get('price') ?? 500, managed: flags.has('managed') });
1041
1041
  if (sub === 'check') return shopCheck(root);
1042
- if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), log });
1042
+ if (sub === 'connect') return shopConnect(root, { managed: flags.has('managed') ? true : null, live: flags.has('live'), manual: flags.has('manual'), renew: flags.has('renew'), log: (line) => process.stderr.write(`${line}\n`) });
1043
1043
  if (sub === 'disconnect') return shopDisconnect(root);
1044
1044
  if (sub === 'catalog') {
1045
1045
  const from = flags.get('have');
package/lib/build.mjs CHANGED
@@ -18,7 +18,7 @@
18
18
  * site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
19
19
  * game.json files, the music/ and videos/ manifests (media/MEDIA.md),
20
20
  * posts/*.md, the studio's site/ folder (site/SITE.md) and shop.json (checked
21
- * against the kit's rules: a shop that breaks them stops the build)
21
+ * against studio settings, Stripe constraints and arithmetic correctness)
22
22
  * site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
23
23
  * site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
24
24
  * site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
@@ -489,7 +489,7 @@ export function orderGames(games, order, log = () => {}) {
489
489
  export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
490
490
  const esbuild = await studioEsbuild(root);
491
491
  const studio = readStudio(root);
492
- // The shop first (shop/SHOP.md): a shop.json that breaks the kit's rules stops the build before anything is built.
492
+ // The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
493
493
  const shop = shopForBuild(root, { log });
494
494
  const live = join(root, 'site', 'dist');
495
495
  const games = listGames(root).filter((g) => !only || g.id === only);
@@ -677,7 +677,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
677
677
  : studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
678
678
  // A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
679
679
  ...(studio.template === true ? { template: true } : {}),
680
- // studio.json "audience": "kids" (a studio made for children: no shop at all) or "teens"; and "referrals": false
680
+ // studio.json "audience": "kids" (a studio audience label; shop policies are optional) or "teens"; and "referrals": false
681
681
  // (this studio takes no referral statements as a referrer).
682
682
  ...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
683
683
  ...(studio.referrals === false ? { referrals: false } : {}),
@@ -238,6 +238,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
238
238
  const wide = wideRouteRefusal(readConfig(root), studio);
239
239
  if (wide) return { ok: false, command: 'deploy', ...wide };
240
240
  let routes = keptRoutes(root, studio);
241
+ const triggers = readConfig(root)?.triggers;
241
242
  const who = whoami(root);
242
243
  if (!who) {
243
244
  if (cf.auth === 'stripe-projects') {
@@ -352,7 +353,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
352
353
  const r2 = storage;
353
354
  if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
354
355
 
355
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
356
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
356
357
  if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
357
358
  for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
358
359
  const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
@@ -360,7 +361,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
360
361
  step('D1 migrations applied');
361
362
  // Workers AI (0.17.0): bound only when a server's AI guides think with it.
362
363
  if (needsWorkersAi(root, w, cf.d1)) {
363
- writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
364
+ writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
364
365
  step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
365
366
  }
366
367
 
package/lib/doctor.mjs CHANGED
@@ -24,9 +24,9 @@
24
24
  * GitHub optional a private backup, publishing by pull request, building from the Claude app
25
25
  * ElevenLabs optional songs and game scores
26
26
  * fal optional painted art and generated video
27
- * Stripe for selling only in a studio with shop.json (0.24.3): Stripe's own agent plugin (its MCP server
28
- * and skills) for this AI, set up when the studio starts selling; the shop's key itself
29
- * goes in through `homie-studio shop connect`, never here
27
+ * Stripe for selling only in a studio with shop.json: official CLI browser connection first; optional
28
+ * agent tooling is reported separately. `shop connect` names payment readiness;
29
+ * a CLI or MCP installation alone never proves the Worker can sell.
30
30
  * Standalone builds optional only in a studio where a game.json has a "standalone" block: Xcode, the Android SDK
31
31
  * and a JDK it builds on, Apple signing (a count of identities, never a name),
32
32
  * notarization and steamcmd (lib/standalone.mjs standaloneRows)
@@ -401,19 +401,19 @@ export async function setupStatus({
401
401
  fix: fal.key && fal.valid !== false ? null : { who: 'person', open: 'https://fal.ai/dashboard/keys', say: 'Make a key, put FAL_KEY in the environment your AI runs in (for example a line in your shell profile), and start a new session. Never paste it into the chat.' },
402
402
  });
403
403
 
404
- // Stripe, only in a studio that sells (shop.json): Stripe's own agent plugin, set up when selling starts (lazy, like
405
- // every provider here). The shop's key never comes through here: `homie-studio shop connect` takes it on a page.
404
+ // Stripe, only in a studio that sells. Installing agent tooling is not proof of Worker credentials.
405
+ // shop connect runs browser pairing and reports the remaining steps; this read-only status never reads keys.
406
406
  if (root && existsSync(join(root, 'shop.json'))) {
407
407
  const v = await exec('stripe', ['--version']);
408
408
  const version = v.code === 0 ? /(\d+\.\d+\.\d+)/.exec(v.stdout)?.[1] ?? null : null;
409
409
  const mcp = stripeAgentConfigured({ root, env });
410
410
  rows.push({
411
- id: 'stripe', label: 'Stripe', need: 'for selling', state: mcp ? 'ok' : 'act',
412
- detail: [mcp ? `Stripe's MCP is set up for ${mcp}` : 'Stripe\'s MCP is not set up for this AI', version ? `the Stripe CLI ${version}` : 'no Stripe CLI'].join('; '),
413
- unlocks: 'the shop: your AI makes the catalog, checks tax and Managed Payments and answers "how are sales?" with Stripe\'s own tools, on your own Stripe account; you sign in on Stripe\'s page',
414
- fix: mcp ? null : {
415
- who: 'ai', run: 'npm install -g @stripe/cli@latest && stripe agent setup',
416
- say: 'Stripe\'s own agent plugin for Claude Code and Codex (its MCP server and skills, kept up to date); you approve the install. Then sign in once on Stripe\'s page and give access to your Stripe sandbox first.',
411
+ id: 'stripe', label: 'Stripe', need: 'for selling', state: 'act',
412
+ detail: [version ? `Stripe CLI ${version} is installed` : 'Stripe CLI is not installed', mcp ? `optional MCP is set up for ${mcp}` : 'optional MCP is not set up', 'CLI access is not proof that Worker credentials or a purchase work'].join('; '),
413
+ unlocks: 'Connect the studio’s own Stripe with browser approval; shop connect syncs Payment Links and the webhook without a Worker API key. Verify a test purchase before calling the shop ready.',
414
+ fix: {
415
+ who: 'ai', run: version ? 'npx --no-install homie-studio shop connect' : 'npm install -g @stripe/cli@latest',
416
+ say: 'Use stripe_login or shop connect. Never ask for a key in chat. Sync Products, Prices, Payment Links and the webhook through the approved CLI. The Worker needs only the signing secret; repeat connect after edits.',
417
417
  open: 'https://dashboard.stripe.com/register',
418
418
  },
419
419
  });
package/lib/mcp-tools.mjs CHANGED
@@ -1102,6 +1102,21 @@ export function toolDefs(ctx, avail = {}) {
1102
1102
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1103
1103
  },
1104
1104
  },
1105
+ {
1106
+ name: 'stripe_login', title: 'Connect the studio to Stripe',
1107
+ description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. An existing key supports fuller checkout; manual only if explicitly chosen for those features. live only when the owner says go live.',
1108
+ inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' }, manual: { type: 'boolean' } } },
1109
+ annotations: { title: 'Connect Stripe', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1110
+ run: async (a) => {
1111
+ const root = ctx.root(a.studio);
1112
+ const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : []), ...(a.manual ? ['--manual'] : [])];
1113
+ const job = cliJob(root, 'Stripe connection', args, { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs'), keep: false });
1114
+ const ended = await waitJob(job, Math.min(ctx.waitMs, 1000));
1115
+ ctx.status = null;
1116
+ if (ended && job.code !== 0) return fail(whyOf(job), { kind: 'job', ...jobView(job) });
1117
+ return ok(ended ? jobView(job).tail.join('\n') || 'Stripe connection finished; inspect the job result.' : 'Stripe connection is running. Approve Stripe in the browser when it opens. Check this job for the result and any remaining setup step.', { kind: 'job', ...jobView(job) });
1118
+ },
1119
+ },
1105
1120
  {
1106
1121
  name: 'cloudflare_login', title: 'Sign in to Cloudflare',
1107
1122
  description: 'Open Cloudflare in the person\'s browser to approve this computer once (Wrangler\'s own sign-in; a free account, no payment method, no key pasted anywhere). Say in one line that Cloudflare opened and they should approve it.',
package/lib/mcp.mjs CHANGED
@@ -40,6 +40,7 @@ A new studio follows one checklist, in order and never ahead. Show it in your fi
40
40
  3. One small change from one sentence of theirs: to the copied game, or to the studio's home (site/theme.json colours, a tagline in studio.json, a first post in posts/). Then build and preview_run, and they reload.
41
41
  4. Plan their game (game_plan): a short interview, two or three questions a message with options and your pick; then fill games/<id>/CODEX.md and show it (game_codex). Look for pieces first: the @homie-rocks/* packages for general mechanisms, and parts_find for pieces other studios shared (part_add brings one in).
42
42
  5. Build it: build_open, then game_make under the planned id (the codex stays), file edits (few and large: one file_edit carries every change to a file in its edits list, never a call per change), build, preview_run, check. The card follows every build.
43
+ For a shop: stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through Stripe CLI browser approval. Follow studio_job. Default is keyless: no API key in the Worker, no human-handled key. Rerun after shop.json changes. Never expose CLI credentials or webhook secrets. Follow the single next step, verify purchase/webhook/grant and a refund in Stripe or through the connected AI. An existing Worker key selects fuller carts, caps, expiry, automatic recovery and office refunds; explain that only when requested. Never choose manual automatically. Lost webhook: resend from Stripe, never grant from the return URL. Live only when requested.
43
44
  6. Playtest it (playtest), then put it online: studio_deploy with plan: true first (say what it creates and costs, free), cloudflare_login when not signed in (they approve once in their browser), studio_deploy, then studio_publish.
44
45
  If they ask for everything at once, show the list, make your own choices for steps 2 to 4 in one line each, and go on.
45
46
 
package/lib/scaffold.mjs CHANGED
@@ -29,7 +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, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE } from '../worker/shop-store.mjs';
32
+ import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE, SHOP_LINES, SHOP_LINES_FILE } from '../worker/shop-store.mjs';
33
33
  import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
34
34
  import { themeFile } from './site.mjs';
35
35
  import { rulesIndex } from './studio.mjs';
@@ -64,7 +64,7 @@ export function slugify(name) {
64
64
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
65
65
  * the binding names and keep it linked.
66
66
  */
67
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
67
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
68
68
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
69
69
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
70
70
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -85,6 +85,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
85
85
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
86
86
  // (lib/routes.mjs). A studio without routes gets no key at all.
87
87
  ...(routes?.length ? { routes } : {}),
88
+ ...(triggers ? { triggers } : {}),
88
89
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
89
90
  durable_objects: { bindings: rooms },
90
91
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
@@ -433,41 +434,37 @@ studio's pinned copy, never a registry lookup of the bare name.
433
434
 
434
435
  ## Selling things (the shop)
435
436
 
436
- - **The studio sells with its OWN Stripe account.** The studio is the seller: its prices, its refunds, its tax, its
437
- disputes. Money goes from players to the studio's Stripe; homie.rocks never sees it and Homie takes no cut.
438
- \`SELLING.md\` says what that means for the owner in plain words (not legal advice).
439
- - **\`shop.json\`** is the studio's settings file: \`till\` (\`stripe\`, \`stripe-managed\`, or \`off\`), \`currency\`,
440
- optional \`refundDays\` and \`capPerPlayerMonth\`, and items with studio-chosen kinds, names, prices and entitlement
441
- keys. No Homie ceiling on prices, tips, item count, keys, text, durations or referral terms. Stripe currency
442
- units and provider minimums still apply, including zero-decimal currencies. \`shop init --supporter\` writes a
443
- supporter item and explicit protective policy, with no spending cap or refund window. Existing values stay.
444
- - **The studio chooses who may buy:** \`policy.preset\` is \`protective\` (the default), \`adults-only\`, or \`custom\`.
445
- Each rule can be read and edited in shop.json; SHOP.md lists them. Protective keeps the account and age
446
- question, under-13 refusal, parent checkout for teens, closed kids shops, beginner fairness, refusal of paid
447
- randomness and countdowns, and TV code to a phone. Do not silently relax protection for children. The studio
448
- is responsible for law and provider terms where it sells; presets are not legal advice.
449
- - **Flood protection:** \`purchaseAttemptsPerMinute\` (default 6) is per account; \`purchaseAttemptsPerAddressPerMinute\` (default 600) is per address. Both are per Worker instance and configurable.
450
- New guest buyers use \`guestBuyersPerAddressPerHour\` (default 600). These protect against floods, not spending.
451
- - **In a game:** \`import { createShop } from '@homie-rocks/studio/shop'\`; \`shop.has('skin:ember')\`,
452
- \`shop.entitlements()\`, \`shop.on('change', …)\`, \`shop.open(item)\` from a button the player pressed (never the
453
- play button, never on a timer), \`shop.used(key)\` when it is equipped. A supporter's badge rides on their seat
454
- (\`peer.badge\`, set by the Worker, never by a hello). The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
455
- - **Stripe's own tools** (set up when the studio starts selling): \`npm install -g @stripe/cli@latest && stripe agent
456
- setup\` installs Stripe's agent plugin (its MCP server and skills); the owner signs in once on Stripe's page and
457
- gives access to a sandbox first. With it you make the catalog (\`npx --no-install homie-studio shop catalog\` says
458
- the read, then the exact \`stripe_api_write\` calls), check tax settings, and answer "how are sales?" read-only.
459
- Never make a webhook or an API key through the MCP (a secret would land in the chat), and never retry a write
460
- Stripe sent to the owner for confirmation until they approved it.
461
- - **The key** goes in only from a page on the owner's own computer: \`npx --no-install homie-studio shop connect\`
462
- prints a 127.0.0.1 link; the owner pastes one restricted key there, the page makes the webhook with it, and the
463
- key and the webhook's secret go straight to the Worker secrets. Test keys only unless \`--live\`. Never ask for a
464
- key in the chat; never write one anywhere.
465
- - **Refunds:** the owner's one tap in the office (\`/_studio/office/shop\`). You may only ASK:
466
- \`homie-studio shop refund <order>\` gives the owner a one-tap link. A card dispute never touches the player's
467
- account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
468
- - **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
469
- player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
470
- signs them; the referrer invoices the studio. Nothing moves through Homie.
437
+ Your shop, your choices. The studio sells through its own Stripe account and is responsible for the law
438
+ where it sells and for Stripe's terms. Homie takes no cut.
439
+
440
+ - \`shop.json\` lists items, wording, kinds, prices, quantities and entitlements. Optional settings include
441
+ sale dates, tips, currency, \`automaticTax\`, refund windows, spending caps and referral terms.
442
+ - The default is open: guests can buy repeatedly, without age questions, from any page. Signing in keeps
443
+ guest purchases. \`shop init --supporter\` writes an example item without a policy, cap or refund window.
444
+ - Choose \`policy.preset: "protective"\` or \`"adults-only"\` to enable an account and age policy. Omitted
445
+ presets, including in existing files, use the open default. Individual overrides are in SHOP.md.
446
+ - \`createShop()\` from \`@homie-rocks/studio/shop\` supplies \`open\`, \`has\`, \`entitlements\`, \`used\`, and \`change\`.
447
+ \`shop.add(item, quantity)\` builds a cart; \`shop.checkout()\` pays; \`shop.buy(item)\` buys directly.
448
+ - With the fuller connection, a cart has independent lines and quantities. The office can refund a whole order or line. The optional
449
+ player refund window covers items; a tip is never taken back by the player.
450
+ - Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
451
+ new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
452
+ - \`shop check\` validates settings; \`stripe_login\` / \`shop connect\` starts Stripe browser approval.
453
+ Never ask a person for a key. Follow the result's next step: deploy first if needed, then rerun connect.
454
+ The default syncs Products, Prices, Payment Links and the webhook through the approved Stripe CLI.
455
+ The Worker stores only links and a signing secret; buying needs no API key or running AI.
456
+ Rerun \`shop connect\` after shop.json changes; use \`shop connect --renew\` if approval needs renewal.
457
+ Verify a test purchase, signed webhook, item grant, and a refund in Stripe or through the connected AI.
458
+ Never ask a person for a key. An existing Worker key selects the fuller cart/cap/refund path;
459
+ explain it only when those features are requested. Never silently choose \`--manual\`. Live only on request.
460
+ A lost webhook stays pending: ask Stripe to resend it. Free orders need no Stripe.
461
+ Never create secret-returning webhooks through conversation MCP tools; connect captures secrets privately.
462
+ Stripe's approval links belong to the owner. \`checkoutMinutes\` is optional (Stripe: 30 to 1440 minutes,
463
+ default 1440; values below 31 use 31 for transport margin); cancelling a named checkout, or replacing one at least a minute old, expires the open session on Stripe's confirmation.
464
+ - \`shop orders\` and \`/_studio/office/shop\` show sales. \`shop refund <order>\` asks the owner to confirm.
465
+ \`shop statements\` records optional referral shares; studios pay their referrers directly.
466
+
467
+ The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
471
468
 
472
469
  ## Continuing a build from the Claude app
473
470
 
@@ -730,6 +727,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
730
727
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
731
728
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
732
729
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
730
+ [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
733
731
  [`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
734
732
  'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
735
733
  '.claude/skills/.gitkeep': '',
@@ -852,6 +850,14 @@ function ensureShopStatements(root) {
852
850
  return `site/migrations/${SHOP_STATEMENTS_FILE}`;
853
851
  }
854
852
 
853
+ function ensureShopLines(root) {
854
+ const file = join(root, 'site', 'migrations', SHOP_LINES_FILE);
855
+ if (existsSync(file)) return null;
856
+ mkdirSync(dirname(file), { recursive: true });
857
+ writeFileSync(file, SHOP_LINES);
858
+ return `site/migrations/${SHOP_LINES_FILE}`;
859
+ }
860
+
855
861
  /** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
856
862
  /** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
857
863
  function ensureChatMigration(root) {
@@ -872,12 +878,12 @@ function ensureLoungeMigration(root) {
872
878
  }
873
879
 
874
880
  export function ensureMigrations(root) {
875
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
881
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
876
882
  }
877
883
 
878
884
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
879
885
  export function migrationWord(file) {
880
- return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /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';
886
+ return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /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';
881
887
  }
882
888
 
883
889
  /**
@@ -31,11 +31,11 @@ export async function wantedProducts(shop, slug) {
31
31
  return Promise.all((shop?.items ?? []).map(async (it) => {
32
32
  const id = await productIdOf(slug, it.id);
33
33
  return {
34
- id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
34
+ id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: it.taxCode ?? (shop.till === 'stripe-managed' ? defaultTaxCode(it) : null),
35
35
  // Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
36
36
  metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
37
- price: it.kind === 'tip' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
38
- shown: it.kind === 'tip' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
37
+ price: it.price === 'choose' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
38
+ shown: it.price === 'choose' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
39
39
  };
40
40
  }));
41
41
  }
@@ -96,7 +96,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
96
96
  if (!p) {
97
97
  writes.push({
98
98
  tool: 'stripe_api_write', method: 'POST', path: '/v1/products', why: `make ${w.name} (${w.shown})`,
99
- params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), tax_code: w.tax_code, metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
99
+ params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), ...(w.tax_code ? { tax_code: w.tax_code } : {}), metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
100
100
  });
101
101
  continue;
102
102
  }
@@ -0,0 +1,91 @@
1
+ /** Reconcile through the owner's official Stripe CLI session; never export its credentials. */
2
+ import { createHash, randomUUID } from 'node:crypto';
3
+ import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
4
+ import { defaultTaxCode } from '../worker/shop-rules.mjs';
5
+ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/shop-links.mjs';
6
+ import { HOOK_EVENTS } from './shop.mjs';
7
+ const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
+ const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
+ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt, saveReceipt }) {
10
+ const list = async (path, params = {}) => {
11
+ const all = []; let cursor;
12
+ do {
13
+ const p = await api('GET', path, { ...params, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
14
+ if (!Array.isArray(p.data)) throw new Error('Incomplete Stripe list');
15
+ all.push(...p.data);
16
+ const next = p.has_more ? p.data.at(-1)?.id : null;
17
+ if (p.has_more && (!next || next === cursor)) throw new Error('Incomplete Stripe pagination');
18
+ cursor = next;
19
+ } while (cursor);
20
+ return all;
21
+ };
22
+ const secrets = await w(['secret', 'list']);
23
+ if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
24
+ const names = JSON.parse(secrets.stdout ?? secrets.out);
25
+ const has = (name) => names.some((s) => s.name === name);
26
+ const products = await list('/v1/products');
27
+ const prices = await list('/v1/prices');
28
+ const links = await list('/v1/payment_links');
29
+ const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
30
+ const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
31
+ const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
32
+ for (const item of shop.items) {
33
+ const id = await productIdOf(slug, item.id);
34
+ keepProducts.add(id);
35
+ const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
36
+ const fields = { name: item.name, description: item.blurb ?? '', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
37
+ let product = products.find((p) => p.id === id);
38
+ if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
39
+ else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
+ const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
41
+ ? { custom_unit_amount: { enabled: true, minimum: item.min, ...(item.max !== null ? { maximum: item.max } : {}) } }
42
+ : { unit_amount: item.price }) };
43
+ const revision = hash(priceFields);
44
+ let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
45
+ if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
46
+ if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
47
+ keepPrices.add(price.id);
48
+ if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
49
+ if (item.price === 0) continue; // Free orders never leave the studio.
50
+ const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
51
+ after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
52
+ ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
53
+ payment_intent_data: { metadata },
54
+ };
55
+ const linkRevision = hash({ params, item, policy: shop.policy });
56
+ let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
57
+ if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
58
+ if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
59
+ if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
60
+ keepLinks.add(link.id);
61
+ config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
62
+ }
63
+ const endpoints = await list('/v1/webhook_endpoints');
64
+ const url = `${site}/api/shop/hook`;
65
+ const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events?.includes(event)));
66
+ let endpoint = current, secret;
67
+ if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
68
+ const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
69
+ saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
70
+ endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...HOOK_EVENTS], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
71
+ if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
72
+ secret = endpoint.secret;
73
+ }
74
+ const fingerprint = hash(config);
75
+ const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
76
+ if (!unchanged) {
77
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
78
+ if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
79
+ // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
80
+ if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
81
+ saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
82
+ }
83
+ // Retire only after the replacement configuration and signing secret are installed.
84
+ for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
85
+ for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
86
+ for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
87
+ for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
88
+ return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
89
+ message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
90
+ next: ['Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
91
+ }