@homie-rocks/studio 0.34.0 → 0.35.1

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,33 @@ 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.1] - 2026-10-08
21
+
22
+ **Plugin 0.36.0** · [#71](https://github.com/homie-rocks/homie/pull/71)
23
+
24
+ A tip with no minimum connects, and Stripe's own reason is shown when setup cannot finish.
25
+
26
+ ### Fixed
27
+
28
+ - A pay-what-you-want item with no minimum connects.
29
+ - When Stripe refuses something during setup, you are told what Stripe said. Keys are masked, including during renewal, catalog reads and the manual connection.
30
+ - The test Stripe now refuses invalid amounts and incomplete shop objects, so these mistakes fail a test before release.
31
+
32
+ ## [0.35.0] - 2026-10-08
33
+
34
+ **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67) · [release-2026-10-08-studio-0.35.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.35.0)
35
+
36
+ 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.
37
+
38
+ ### Added
39
+
40
+ - 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.
41
+ - 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.
42
+ - 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.
43
+
20
44
  ## [0.34.0] - 2026-10-08
21
45
 
22
- **Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64)
46
+ **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
47
 
24
48
  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
49
 
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
  /**
@@ -22,7 +22,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
22
22
  import { readStudio } from './studio.mjs';
23
23
  import { readShop } from './shop.mjs';
24
24
  import { defaultTaxCode, money } from '../worker/shop-rules.mjs';
25
- import { productIdOf } from '../worker/stripe.mjs';
25
+ import { productIdOf, redactStripe } from '../worker/stripe.mjs';
26
26
 
27
27
  const MARK = 'shop-v1';
28
28
 
@@ -58,6 +58,22 @@ export function productsIn(value, out = [], depth = 0) {
58
58
  return out;
59
59
  }
60
60
 
61
+ // Saved MCP responses can wrap a Stripe refusal in text or structured content.
62
+ function catalogError(value, depth = 0) {
63
+ if (depth > 8 || value == null) return null;
64
+ if (typeof value === 'string') {
65
+ try { return catalogError(JSON.parse(value), depth + 1); } catch { return null; }
66
+ }
67
+ if (typeof value !== 'object') return null;
68
+ if (typeof value.error?.message === 'string') return value.error.message;
69
+ if (value.isError === true) return (value.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join(' ');
70
+ for (const child of Array.isArray(value) ? value : ['data', 'content', 'text', 'result', 'results', 'structuredContent'].map((k) => value[k])) {
71
+ const error = catalogError(child, depth + 1);
72
+ if (error) return error;
73
+ }
74
+ return null;
75
+ }
76
+
61
77
  const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
62
78
 
63
79
  /** The plan: the read first, then (with what Stripe answered) the writes still needed. */
@@ -83,6 +99,8 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
83
99
  ],
84
100
  };
85
101
  }
102
+ const error = catalogError(have);
103
+ if (error) return { ok: false, command: 'shop catalog', why: `Stripe said: “${redactStripe(error).slice(0, 400)}” (GET /v1/products). Read the catalog again after resolving it.` };
86
104
  const products = productsIn(have);
87
105
  const ours = products.filter((p) => p.metadata?.homie_studio === String(slug) || wanted.some((w) => w.id === p.id));
88
106
  const modes = [...new Set(ours.map((p) => (p.livemode ? 'live' : 'test')))];
@@ -149,6 +167,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
149
167
 
150
168
  export function catalogLines(r) {
151
169
  const lines = [];
170
+ if (!r.ok) return [r.why];
152
171
  if (r.step === 'read') {
153
172
  lines.push('The catalog shop.json asks for (one Product an item, the same ids in the sandbox and live):');
154
173
  for (const p of r.products) lines.push(` ${p.id} ${p.name} ${p.price} (tax code ${p.tax_code})`);
@@ -0,0 +1,93 @@
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
+ // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
+ // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
42
+ const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
43
+ ? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
44
+ : { unit_amount: item.price }) };
45
+ const revision = hash(priceFields);
46
+ let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
47
+ if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
48
+ if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
49
+ keepPrices.add(price.id);
50
+ if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
51
+ if (item.price === 0) continue; // Free orders never leave the studio.
52
+ const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
53
+ after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
54
+ ...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
55
+ payment_intent_data: { metadata },
56
+ };
57
+ const linkRevision = hash({ params, item, policy: shop.policy });
58
+ let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
59
+ if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
60
+ if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
61
+ if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
62
+ keepLinks.add(link.id);
63
+ config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
64
+ }
65
+ const endpoints = await list('/v1/webhook_endpoints');
66
+ const url = `${site}/api/shop/hook`;
67
+ const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events?.includes(event)));
68
+ let endpoint = current, secret;
69
+ if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
70
+ const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
71
+ saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
72
+ 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}`);
73
+ if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
74
+ secret = endpoint.secret;
75
+ }
76
+ const fingerprint = hash(config);
77
+ const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
78
+ if (!unchanged) {
79
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
80
+ 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.' };
81
+ // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
82
+ 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.' };
83
+ saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
84
+ }
85
+ // Retire only after the replacement configuration and signing secret are installed.
86
+ 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 });
87
+ for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
88
+ for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
89
+ 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 });
90
+ return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
91
+ message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
92
+ next: ['Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
93
+ }
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 } = {}) {
@@ -201,7 +205,7 @@ async function makeWebhook(env, site, { fetcher }) {
201
205
  const list = await listWebhookEndpoints(env, { fetcher });
202
206
  older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
203
207
  } catch (error) {
204
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
208
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
205
209
  return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
206
210
  }
207
211
  let made = null;
@@ -211,7 +215,7 @@ async function makeWebhook(env, site, { fetcher }) {
211
215
  description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
212
216
  }, { fetcher });
213
217
  } catch (error) {
214
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
218
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
215
219
  return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
216
220
  }
217
221
  const secret = typeof made?.secret === 'string' ? made.secret : '';
@@ -232,7 +236,7 @@ async function probeManaged(env, shop, { fetcher }) {
232
236
  line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
233
237
  managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
234
238
  }, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
235
- if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
239
+ if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch (error) { return { ok: false, words: `Stripe took the test checkout but could not expire it (${error instanceof StripeError ? error.message : 'no answer'}). It will expire by itself.` }; } }
236
240
  return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
237
241
  } catch (error) {
238
242
  return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
@@ -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 {
@@ -280,14 +284,16 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
280
284
  // One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
281
285
  // except to Stripe itself.
282
286
  if (verify) {
283
- try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
284
- say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
287
+ try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch (error) {
288
+ const reason = error instanceof StripeError ? ` Stripe said: ${error.message}` : '';
289
+ log(`Stripe could not read checkouts.${reason}`);
290
+ say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.' + reason)); return;
285
291
  }
286
292
  }
287
293
  let webhook = { made: false };
288
294
  if (!hook) {
289
295
  const m = await makeWebhook(sk, site, { fetcher });
290
- if (!m.ok) { say(m.page); return; }
296
+ if (!m.ok) { log(m.page.text); say(m.page); return; }
291
297
  hook = m.secret;
292
298
  webhook = { made: true, id: m.id, older: m.older };
293
299
  }
@@ -299,10 +305,13 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
299
305
  // The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
300
306
  // not deleted: the owner can turn one back on in Stripe).
301
307
  let disabled = 0;
308
+ const warnings = [];
302
309
  if (okay && webhook.made) {
303
- for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
310
+ for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch (error) { warnings.push(`Stripe could not turn off webhook ${id}: ${error instanceof StripeError ? error.message : 'no answer'}`); } }
304
311
  }
305
312
  const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
313
+ if (check && !check.ok) warnings.push(check.words);
314
+ for (const warning of warnings) log(warning);
306
315
  let tillNote = '';
307
316
  if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
308
317
  try {
@@ -313,13 +322,14 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
313
322
  } catch { tillNote = ''; }
314
323
  }
315
324
  const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
316
- say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
325
+ say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check?.ok ? ` ${check.words}` : ''}${warnings.length ? ` ${warnings.join(' ')}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
317
326
  server.close();
318
327
  done(okay
319
328
  ? {
320
329
  ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
321
330
  webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
322
331
  ...(check ? { managedPayments: check } : {}),
332
+ ...(warnings.length ? { warnings } : {}),
323
333
  message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
324
334
  }
325
335
  : { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
@@ -342,7 +352,8 @@ export function shopDisconnect(root) {
342
352
  const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
343
353
  const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
344
354
  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(' ') };
355
+ const c = w(['secret', 'delete', 'STRIPE_SHOP_LINKS'], { input: 'y\n' });
356
+ 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
357
  }
347
358
 
348
359
  /** Lines for a person (the CLI's print). */
@@ -351,6 +362,7 @@ export function shopLines(r) {
351
362
  if (r.command === 'shop') {
352
363
  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
364
  for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
365
+ if (r.connection) lines.push(`Stripe test credentials expire ${r.connection.expiresAt}. Renew: ${r.connection.renew}`);
354
366
  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
367
  lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
356
368
  if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
@@ -372,6 +384,9 @@ export function shopLines(r) {
372
384
  if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
373
385
  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
386
  if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
375
- } else lines.push(r.message ?? '');
387
+ } 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
388
  return lines;
377
389
  }
390
+
391
+ // Public entry point: browser pairing by default; the local key page is explicitly opt-in.
392
+ export { shopConnect } from './stripe-connect.mjs';