@homie-rocks/studio 0.34.0 → 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,21 @@ 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
+
20
32
  ## [0.34.0] - 2026-10-08
21
33
 
22
- **Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64)
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)
23
35
 
24
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.
25
37
 
package/README.md CHANGED
@@ -460,18 +460,22 @@ The studio chooses its settings in shop.json; [shop/SHOP.md](shop/SHOP.md) expla
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.
474
+
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.
475
479
 
476
480
  In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`, `add`, `checkout`, `buy`); a
477
481
  supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), optional Stripe Tax, or Stripe Managed
@@ -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/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
@@ -445,13 +445,20 @@ where it sells and for Stripe's terms. Homie takes no cut.
445
445
  presets, including in existing files, use the open default. Individual overrides are in SHOP.md.
446
446
  - \`createShop()\` from \`@homie-rocks/studio/shop\` supplies \`open\`, \`has\`, \`entitlements\`, \`used\`, and \`change\`.
447
447
  \`shop.add(item, quantity)\` builds a cart; \`shop.checkout()\` pays; \`shop.buy(item)\` buys directly.
448
- - A cart has independent lines and quantities. The office can refund a whole order or line. The optional
448
+ - With the fuller connection, a cart has independent lines and quantities. The office can refund a whole order or line. The optional
449
449
  player refund window covers items; a tip is never taken back by the player.
450
450
  - Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
451
451
  new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
452
- - \`shop check\` validates settings; \`shop connect\` provides a local page for credentials, stored as Worker
453
- secrets. Stripe's agent tools can manage Products through \`shop catalog\`; the connect page creates the
454
- webhook so its secret stays out of chat. Both the key and signing secret are part of working payment setup.
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.
455
462
  Stripe's approval links belong to the owner. \`checkoutMinutes\` is optional (Stripe: 30 to 1440 minutes,
456
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.
457
464
  - \`shop orders\` and \`/_studio/office/shop\` show sales. \`shop refund <order>\` asks the owner to confirm.
@@ -876,7 +883,7 @@ export function ensureMigrations(root) {
876
883
 
877
884
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
878
885
  export function migrationWord(file) {
879
- 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';
880
887
  }
881
888
 
882
889
  /**
@@ -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
+ }
package/lib/shop.mjs CHANGED
@@ -6,7 +6,8 @@
6
6
  * homie-studio shop init [--supporter] [--currency usd] [--price 500] [--managed]
7
7
  * writes shop.json (a $5 Supporter pack with --supporter) and SELLING.md
8
8
  * homie-studio shop check shop.json against the studio settings and provider requirements
9
- * homie-studio shop connect [--managed] [--live] a page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
9
+ * homie-studio shop connect sync Payment Links and webhook with the approved Stripe CLI; no Worker API key
10
+ * homie-studio shop connect --manual [--managed] [--live] a fallback page on THIS computer (127.0.0.1, one use, ten minutes) where the owner
10
11
  * pastes the studio's restricted Stripe key (the one thing only they can make);
11
12
  * with it this process makes the webhook (0.24.3), and the key and the
12
13
  * webhook's signing secret go straight to the Worker secrets STRIPE_KEY and
@@ -98,7 +99,7 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
98
99
  writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
99
100
  const wrote = [SHOP_FILE];
100
101
  if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
101
- 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 and webhook signing secret are in)', 'homie-studio shop connect (the owner pastes a Stripe key into a page on this computer)'] };
102
+ return { ok: true, command: 'shop init', wrote, till: shop.till, items: shop.items.map((i) => i.id), next: ['homie-studio shop check', 'npm run deploy (paid sales wait for the Stripe connection)', 'homie-studio shop connect (approve Stripe in your browser; the result names any remaining step)'] };
102
103
  }
103
104
 
104
105
  /** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
@@ -106,7 +107,10 @@ export async function shopStatus(root, { url } = {}) {
106
107
  const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
107
108
  if (!r.ok) return { ok: false, command: 'shop', why: r.message ?? r.why ?? 'the studio did not answer' };
108
109
  const local = readShop(root);
109
- return { ok: true, command: 'shop', ready: r.ready, mode: r.mode, missing: r.missing, totals: r.totals, stripe: r.stripe, hook: r.hook, till: r.shop?.till ?? null, items: r.shop?.items ?? [], local: { ok: local.ok, absent: Boolean(local.absent), errors: local.errors } };
110
+ const { stripeConnectionInfo } = await import('./stripe-connect.mjs');
111
+ const connection = r.mode === 'test' ? stripeConnectionInfo(root) : null;
112
+ const missing = [...(r.missing ?? []), ...(connection?.expired ? [{ id: 'stripe-expired', words: 'Stripe test credentials have expired. Run homie-studio shop connect --renew and approve Stripe again.' }] : [])];
113
+ return { ok: true, command: 'shop', ready: r.ready && !connection?.expired, mode: r.mode, missing, connection, totals: r.totals, stripe: r.stripe, hook: r.hook, till: r.shop?.till ?? null, items: r.shop?.items ?? [], local: { ok: local.ok, absent: Boolean(local.absent), errors: local.errors } };
110
114
  }
111
115
 
112
116
  export async function shopOrders(root, { url } = {}) {
@@ -240,11 +244,11 @@ async function probeManaged(env, shop, { fetcher }) {
240
244
  }
241
245
 
242
246
  /**
243
- * `homie-studio shop connect`: the page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
247
+ * `homie-studio shop connect --manual`: the fallback page above on 127.0.0.1 (one use, ten minutes). The key goes to Stripe once (a
244
248
  * read, then the webhook), and to `wrangler secret put` on its standard input; so does the webhook's signing secret,
245
249
  * which Stripe hands this process when it makes the endpoint. Nothing is printed or returned but ids and words.
246
250
  */
247
- export async function shopConnect(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
251
+ export async function shopConnectManual(root, { managed = null, live = false, log = () => {}, port = 0, wait = 10 * 60_000, verify = true, fetcher = fetch } = {}) {
248
252
  const studio = readStudio(root);
249
253
  const site = siteUrl(root);
250
254
  if (!site) return { ok: false, command: 'shop connect', needs: 'deploy', why: 'the studio has no live address yet, and Stripe needs one to send payments to (the webhook): run npm run deploy first, then this' };
@@ -272,7 +276,7 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
272
276
  if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
273
277
  if (!KEY_SHAPE.test(key)) { say(said(400, 'That does not look like a Stripe key (rk_test_…, rk_live_…, sk_test_… or sk_live_…). Go back and try again.')); return; }
274
278
  // Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
275
- if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE Stripe key (rk_live_… or sk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_… or sk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --live).')); return; }
279
+ if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE Stripe key (rk_live_… or sk_live_…).' : 'This page is for TEST mode: paste a test key (rk_test_… or sk_test_…). Live keys go in only when the shop is ready to sell for real (shop connect --manual --live).')); return; }
276
280
  if (hook && !WEBHOOK_SECRET_SHAPE.test(hook)) { say(said(400, 'That does not look like a webhook signing secret (whsec_…). Go back and try again, or leave it empty and this page makes the webhook.')); return; }
277
281
  busy = true;
278
282
  try {
@@ -342,7 +346,8 @@ export function shopDisconnect(root) {
342
346
  const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
343
347
  const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
344
348
  const b = w(['secret', 'delete', 'STRIPE_WEBHOOK_SECRET'], { input: 'y\n' });
345
- return a.code === 0 || b.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Stripe key and webhook secret are gone from the Worker: the shop is closed. Delete the restricted key in Stripe too (Developers → API keys).' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
349
+ const c = w(['secret', 'delete', 'STRIPE_SHOP_LINKS'], { input: 'y\n' });
350
+ return a.code === 0 || b.code === 0 || c.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Worker payment connection was removed. Ask your connected AI to deactivate existing Payment Links in Stripe too; already opened Stripe checkouts can still complete.' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
346
351
  }
347
352
 
348
353
  /** Lines for a person (the CLI's print). */
@@ -351,6 +356,7 @@ export function shopLines(r) {
351
356
  if (r.command === 'shop') {
352
357
  lines.push(r.ready ? `The shop is open${r.mode === 'test' ? ' in TEST MODE (no real money)' : ', live'} (till: ${r.till}).` : 'The shop is not selling yet:');
353
358
  for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
359
+ if (r.connection) lines.push(`Stripe test credentials expire ${r.connection.expiresAt}. Renew: ${r.connection.renew}`);
354
360
  if (r.totals) lines.push(`Last 30 days: ${r.totals.sales} sales, ${money(r.totals.paid, r.totals.currency)} before tax and fees; ${r.totals.refunds} refunded; ${r.totals.disputes} disputed.`);
355
361
  lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
356
362
  if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
@@ -372,6 +378,9 @@ export function shopLines(r) {
372
378
  if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
373
379
  else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
374
380
  if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
375
- } else lines.push(r.message ?? '');
381
+ } else { lines.push(r.message ?? r.why ?? (r.alreadyConnected ? 'Stripe test credentials are already installed.' : '')); for (const step of r.next ?? []) lines.push(step); if (r.fallback) lines.push(`Optional fallback, only if chosen: ${r.fallback}`); }
376
382
  return lines;
377
383
  }
384
+
385
+ // Public entry point: browser pairing by default; the local key page is explicitly opt-in.
386
+ export { shopConnect } from './stripe-connect.mjs';
@@ -0,0 +1,135 @@
1
+ /** Stripe owns browser authorization. This module owns only secret transport and a non-secret receipt.
2
+ * See docs/stripe-connect-research.md: current CLI OAuth is NOT a Worker credential issuer. */
3
+ import { spawn } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
5
+ import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
6
+ import { homedir } from 'node:os';
7
+ import { join } from 'node:path';
8
+ import { runner } from './cloudflare.mjs';
9
+ import { lockDeploy } from './deploy-state.mjs';
10
+ import { readStudio, siteUrl } from './studio.mjs';
11
+ import { hookUrlOf, readShop, shopConnectManual } from './shop.mjs';
12
+ import { syncPaymentLinks } from './shop-links.mjs';
13
+ import { LINKS_STRIPE_VERSION, fullerReason } from '../worker/shop-links.mjs';
14
+ import { formEncode } from '../worker/stripe.mjs';
15
+
16
+ const hash = (s) => createHash('sha256').update(s).digest('hex');
17
+ const failure = (needs, why, extra = {}) => ({ ok: false, command: 'shop connect', needs, why, ...extra });
18
+ const receiptHome = () => join(homedir(), '.config', 'homie', 'stripe');
19
+ const receiptDir = (root, storage) => join(storage, hash(realpathSync(root)).slice(0, 24));
20
+
21
+ /** Local expiry evidence only; no account query and no credentials in the result. */
22
+ export function stripeConnectionInfo(root, { storage = receiptHome(), now = Date.now } = {}) {
23
+ try {
24
+ const r = JSON.parse(readFileSync(join(receiptDir(root, storage), 'test.json'), 'utf8'));
25
+ if (r.url !== hookUrlOf(siteUrl(root)) || !Number.isFinite(Date.parse(r.expiresAt))) return null;
26
+ return { mode: 'test', expiresAt: r.expiresAt, expired: Date.parse(r.expiresAt) <= now(), renew: 'homie-studio shop connect --renew' };
27
+ } catch { return null; }
28
+ }
29
+
30
+ const json = (s) => { try { return JSON.parse(s); } catch { return null; } };
31
+ // This output stays private, including CLI config, completion, errors and API responses.
32
+ export function stripeProcess(args, { cwd, env = process.env } = {}) {
33
+ const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
34
+ for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
35
+ return new Promise((resolve) => {
36
+ const p = spawn('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
37
+ let stdout = '', stderr = '', ended = false;
38
+ const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
39
+ const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
40
+ p.stdout.on('data', (b) => { stdout += b; if (stdout.length > 4_000_000) p.kill(); });
41
+ p.stderr.on('data', (b) => { stderr += b; if (stderr.length > 1_000_000) p.kill(); });
42
+ p.on('error', (e) => end({ code: 1, missing: e.code === 'ENOENT' }));
43
+ p.on('close', (code) => end({ code: code ?? 1, stdout, stderr }));
44
+ });
45
+ }
46
+
47
+ function openBrowser(url) {
48
+ const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
49
+ const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
50
+ const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
51
+ p.on('error', () => {}); p.unref();
52
+ }
53
+
54
+ const stripeURL = (s) => {
55
+ try { const u = new URL(s); return u.protocol === 'https:' && ['dashboard.stripe.com', 'access.stripe.com'].includes(u.hostname) && !u.username && !u.password && !u.port; } catch { return false; }
56
+ };
57
+ /** Accept only Stripe's known login continuations. Never execute next_step as shell text. */
58
+ export function completionArgs(step) {
59
+ if (step === 'stripe login --complete-device') return ['login', '--complete-device'];
60
+ if (step === 'stripe login --complete-reauth') return ['login', '--complete-reauth'];
61
+ const m = /^stripe login --complete[ =](?:'([^']+)'|"([^"]+)"|(\S+))$/.exec(step ?? '');
62
+ const url = m && (m[1] || m[2] || m[3]);
63
+ return stripeURL(url) ? ['login', '--complete', url] : null;
64
+ }
65
+
66
+ export async function shopConnect(root, options = {}) {
67
+ if (options.manual) {
68
+ const result = await shopConnectManual(root, options);
69
+ if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
70
+ return result;
71
+ }
72
+ const { live = false, renew = false, log = () => {}, exec = stripeProcess, open = openBrowser,
73
+ storage = receiptHome(), wrangler = null } = options;
74
+ const mode = live ? 'live' : 'test';
75
+ const checked = readShop(root);
76
+ if (!checked.ok || checked.absent) return failure('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
77
+ if (options.managed === true && checked.shop.till !== 'stripe-managed') return failure('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
78
+ const id = hash(realpathSync(root)).slice(0, 24);
79
+ const profile = `homie-${id}`;
80
+ const dir = join(storage, id);
81
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
82
+ // Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
83
+ const lock = lockDeploy(dir);
84
+ if (!lock.ok) return failure('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
85
+ try {
86
+ const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
87
+ const installed = await cli(['--version']);
88
+ if (installed.code !== 0) return failure('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
89
+
90
+ // A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
91
+ let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
92
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) {
93
+ const start = await cli(['login', '--non-interactive', ...(renew ? ['--new-session'] : [])]);
94
+ const login = json(start.stdout);
95
+ const complete = completionArgs(login?.next_step);
96
+ if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
97
+ return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.');
98
+ }
99
+ log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
100
+ open(login.browser_url);
101
+ const finished = await cli(complete);
102
+ if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.');
103
+
104
+ probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
105
+ if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.');
106
+ }
107
+ const site = siteUrl(root);
108
+ if (!site) return failure('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
109
+ let target;
110
+ try { target = new URL(site); } catch { return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
111
+ if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
112
+ const api = async (method, path, params, idempotency) => {
113
+ const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
114
+ for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
115
+ const r = await cli(args);
116
+ const body = json(r.stdout);
117
+ if (r.code !== 0 || !body || body.error) throw new Error('Stripe API operation failed');
118
+ return body;
119
+ };
120
+ const reason = fullerReason(checked.shop);
121
+ if (reason) return failure('fuller-checkout', reason, { mode, connected: true });
122
+ const studio = readStudio(root);
123
+ const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
124
+ const receiptFile = join(dir, `${mode}.json`);
125
+ const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
126
+ const result = await syncPaymentLinks({ api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
127
+ saveReceipt: (record) => {
128
+ writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
129
+ renameSync(`${receiptFile}.tmp`, receiptFile);
130
+ } });
131
+ return { ...result, profile };
132
+ } catch {
133
+ return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.');
134
+ } finally { lock.release(); }
135
+ }
@@ -1846,7 +1846,51 @@
1846
1846
  "## Art direction and models": "a48fff7bf0a5f2fc",
1847
1847
  "## Running live games (the back office)": "51ebf2ab756f6e4d",
1848
1848
  "## Servers and AI seats": "98d08705087a33ec",
1849
- "## Selling things (the shop)": "57c04f6526e16890",
1849
+ "## Selling things (the shop)": [
1850
+ "57c04f6526e16890",
1851
+ "6c30b932b0783495",
1852
+ "9e020d69dd6a954e"
1853
+ ],
1854
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1855
+ "## Rules": "2e6b29afb85dcf7e",
1856
+ "## Telling Homie": "80b46fdd9e578db8",
1857
+ "## Beta": "9d02638207a26e73"
1858
+ }
1859
+ },
1860
+ "named": {
1861
+ "Homie Arcade": {
1862
+ "sections": {
1863
+ "AGENTS.md": {
1864
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1865
+ }
1866
+ }
1867
+ }
1868
+ }
1869
+ },
1870
+ "0.35.0": {
1871
+ "files": {
1872
+ "CLAUDE.md": "ddf2b2a0376202f6",
1873
+ "README.md": "684b3cca8001673c",
1874
+ "HANDOFF.md": "29337e2ace95b2e7",
1875
+ "games/README.md": "5ad32fe80c61f117",
1876
+ "music/README.md": "a3dc042dadd39df8",
1877
+ "videos/README.md": "16babc3437d5545b",
1878
+ "posts/README.md": "37841aae476bd540",
1879
+ "site/README.md": "732894273a32dc3f",
1880
+ "site/src/worker.mjs": "fc90f3609cad18ba"
1881
+ },
1882
+ "sections": {
1883
+ "AGENTS.md": {
1884
+ "(intro)": "214aa46eb3a895d5",
1885
+ "## Layout": "8baf49e4291e7e88",
1886
+ "## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
1887
+ "## The site": "0cb8dd9a6f8a0096",
1888
+ "## Making games": "2da7fa74c8884834",
1889
+ "## The Game Codex and progress": "0738ef719cef3a4c",
1890
+ "## Art direction and models": "a48fff7bf0a5f2fc",
1891
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
1892
+ "## Servers and AI seats": "98d08705087a33ec",
1893
+ "## Selling things (the shop)": "9e020d69dd6a954e",
1850
1894
  "## Continuing a build from the Claude app": "0d68081e924ca419",
1851
1895
  "## Rules": "2e6b29afb85dcf7e",
1852
1896
  "## Telling Homie": "80b46fdd9e578db8",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.34.0",
3
+ "version": "0.35.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",