@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 +25 -1
- package/README.md +12 -8
- package/bin/homie-studio.mjs +4 -4
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +12 -5
- package/lib/shop-catalog.mjs +20 -1
- package/lib/shop-links.mjs +93 -0
- package/lib/shop.mjs +31 -16
- package/lib/stripe-connect.mjs +146 -0
- package/lib/template-history.json +85 -1
- package/package.json +1 -1
- package/shop/SELLING.md +4 -2
- package/shop/SHOP.md +55 -17
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +5 -3
- package/worker/shop.mjs +106 -31
- package/worker/stripe.mjs +3 -3
- package/worker/version.mjs +1 -1
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 #
|
|
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
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -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 [--
|
|
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
|
|
28
|
-
*
|
|
29
|
-
*
|
|
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
|
|
405
|
-
//
|
|
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:
|
|
412
|
-
detail: [
|
|
413
|
-
unlocks: 'the
|
|
414
|
-
fix:
|
|
415
|
-
who: 'ai', run: 'npm install -g @stripe/cli@latest
|
|
416
|
-
say: '
|
|
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
|
-
-
|
|
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\`
|
|
453
|
-
|
|
454
|
-
|
|
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 (
|
|
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
|
/**
|
package/lib/shop-catalog.mjs
CHANGED
|
@@ -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
|
|
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 (
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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';
|