@homie-rocks/studio 0.33.1 → 0.35.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +53 -1
- package/README.md +18 -14
- package/bin/homie-studio.mjs +4 -4
- package/lib/build.mjs +3 -3
- package/lib/cloudflare.mjs +3 -2
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +45 -39
- package/lib/shop-catalog.mjs +4 -4
- package/lib/shop-links.mjs +91 -0
- package/lib/shop.mjs +28 -20
- package/lib/stripe-connect.mjs +135 -0
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/shop/SELLING.md +15 -91
- package/shop/SHOP.md +185 -274
- package/shop/shop.ts +20 -5
- package/worker/index.mjs +7 -1
- package/worker/office.mjs +1 -1
- package/worker/players.mjs +1 -1
- package/worker/referrals.mjs +9 -12
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +93 -31
- package/worker/shop-rules.mjs +39 -58
- package/worker/shop-store.mjs +101 -9
- package/worker/shop.mjs +638 -223
- package/worker/stripe.mjs +22 -13
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,9 +17,61 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.35.0] - 2026-10-08
|
|
21
|
+
|
|
22
|
+
**Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67)
|
|
23
|
+
|
|
24
|
+
A shop with no Stripe key to find. You approve once in your browser, and your AI sets up the rest in your own Stripe account.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- New shops use Stripe Payment Links by default: one official CLI browser approval when needed, no human-handled key and no API key in the Worker. Connect syncs Products, fixed/custom-amount Prices, links and the webhook from shop.json; edits replace versions and removals archive them.
|
|
29
|
+
- Signed events fulfill keyless purchases and chosen-amount tips, book refunds and disputes even when delivery is reordered, and reject forged events. Free orders remain local. Office refunds point to Stripe or the owner's connected AI; missing webhooks wait for Stripe redelivery.
|
|
30
|
+
- Keep the existing keyed checkout for dynamic carts, spending reservations, custom expiry, provider-read recovery and office refunds. Readiness explains the next step; skills, guide, scaffold and MCP instructions follow the keyless default. No Homie service enters the money path.
|
|
31
|
+
|
|
32
|
+
## [0.34.0] - 2026-10-08
|
|
33
|
+
|
|
34
|
+
**Plugin 0.35.0** · [#64](https://github.com/homie-rocks/homie/pull/64) · [release-2026-10-08-studio-0.34.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.34.0)
|
|
35
|
+
|
|
36
|
+
A plain shop: nothing is imposed unless you write it. A shop is open to guests by default, the protective rules 0.33.0 started with are a preset you choose, and a player can put several items in a cart and pay once.
|
|
37
|
+
|
|
38
|
+
### Added
|
|
39
|
+
|
|
40
|
+
- **A cart.** A cart holds several items and quantities, free and paid lines together, and is paid in one Stripe Checkout Session. `shop.add(item, quantity, amount?)` and `shop.checkout()` build and pay for a cart; `shop.buy(item)` still buys one thing directly. Each line keeps the item as it was defined at the time of sale, grants exactly once, and can be refunded on its own in the office.
|
|
41
|
+
- A cart with only free lines grants its items without going to Stripe.
|
|
42
|
+
- `checkoutMinutes` in `shop.json` sets how long a checkout stays open, within Stripe's bounds of 30 to 1440 minutes.
|
|
43
|
+
- The shop accepts a restricted (`rk_`) or a full secret (`sk_`) Stripe key. A preview you give its own database and both Stripe secrets can sell; previews normally have no database.
|
|
44
|
+
|
|
45
|
+
### Changed
|
|
46
|
+
|
|
47
|
+
- **Your shop is open by default.** 0.33.0 started every shop on the protective preset. Now a shop that names no preset has no policy: guests can buy, and buy again, without an age question or a parent step, from any page. You choose your items, wording, kinds, prices, dates, caps, refund windows and referral terms. `shop init` writes no policy.
|
|
48
|
+
- **Every policy is optional.** The account, age, kids-server, advantage, repeat-purchase, used-item refund and TV rules apply only when you write them, one by one or with `"preset": "protective"` or `"preset": "adults-only"`. Stripe Tax and the withdrawal acknowledgement are optional too. The toolkit no longer scans your items' wording in any preset. Flood protection stays, with rates you can change.
|
|
49
|
+
- **A player cannot refund a tip.** A refund window you set covers items, including used ones unless you choose otherwise. You can refund any order or any line from your office.
|
|
50
|
+
- A checkout now stays open for Stripe's default of 24 hours instead of 31 minutes.
|
|
51
|
+
- Shop and owned-item pages now ask Stripe about unfinished payments in the background (up to three at a time), instead of only when a purchase was refused by your spending cap. A paid order nobody came back to is found after a minute, with or without a cap, and by your Worker's scheduled handler if you set one.
|
|
52
|
+
- With a spending cap, a refund of one line gives back that line's amount. Before, a partial refund kept the whole price counted.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **The webhook signing secret is required.** The shop is ready only with both the Stripe key and the webhook signing secret, whichever kind of key you use, and the shop's setup steps and readiness name whichever is missing. Payment is confirmed by Stripe's signed event, not only by the buyer's return to your site.
|
|
57
|
+
- Cancelling or replacing a checkout asks Stripe to expire the open session before freeing its place under your spending cap. A checkout with no Stripe session frees its place after the checkout lifetime plus one minute.
|
|
58
|
+
- Refunds follow Stripe's own list of refunds and their statuses. A refund that names a line affects only that line, a pending refund keeps the item, and a failed refund gives it back. A partial refund made in the Stripe Dashboard lowers the order's balance, cap and referral totals without guessing a line. A refund of the whole order takes back the whole order, and a refund of a paid line leaves free lines in the same order alone. A refund event that arrives before its payment was recorded records the payment first.
|
|
59
|
+
- An order in dispute cannot be refunded until Stripe resolves the dispute, and a lost dispute keeps the refunds made before it. Every recorded payment is checked against Stripe's refunds and dispute state.
|
|
60
|
+
- A payment, its items and its referral share are recorded together, including a verified payment that arrives late. A paid order that is missing an item is repaired once.
|
|
61
|
+
- Shop errors no longer show Stripe keys or secrets. Deleting an account clears the checkout's buyer too. An older order whose item name is missing falls back to the catalog; an item with no definition gives an answer that can be retried and a note in the office.
|
|
62
|
+
- Checkout from inside a game keeps the running game in its tab and explains a blocked tab or blocked cookies. When a new tab cannot open, game and TV checkout use the current tab and return to the game or TV page.
|
|
63
|
+
- Signed Stripe events that are not about your shop are ignored. Optional cron triggers survive a deploy.
|
|
64
|
+
|
|
65
|
+
### Upgrade notes
|
|
66
|
+
|
|
67
|
+
- **To keep the 0.33.0 behaviour, write one line.** A `shop.json` that names no preset was protective in 0.33.0 and is open in 0.34.0. To keep the account and age rules you had, write `"policy": { "preset": "protective" }` in `shop.json`. Settings you wrote yourself stay as written. The wording scans are gone in every preset.
|
|
68
|
+
- To keep Stripe Tax, write `"automaticTax": true`. To count referrals for new players only, write `"referralNewPlayersOnly": true`. To keep the shorter checkout, write `"checkoutMinutes": 31`.
|
|
69
|
+
- **Deploy applies one new database step, `0012_shop_lines.sql`.** It only adds: your orders become one-line orders, and nothing is removed, so the 0.33.0 Worker keeps working while you deploy and if you roll back. Until the step has run, owned items and office refunds work and checkout says which step is missing.
|
|
70
|
+
- **The webhook signing secret is required, with either kind of key.** A shop with a key and no signing secret reports not ready and does not sell. `homie-studio shop connect` makes the webhook and stores the secret, or takes one you made yourself.
|
|
71
|
+
|
|
20
72
|
## [0.33.1] - 2026-10-08
|
|
21
73
|
|
|
22
|
-
**Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65)
|
|
74
|
+
**Plugin 0.34.1** · [#65](https://github.com/homie-rocks/homie/pull/65) · [release-2026-10-08-studio-0.33.1](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.33.1)
|
|
23
75
|
|
|
24
76
|
A fix for studios upgrading to 0.33.0, found by upgrading one of Homie's own.
|
|
25
77
|
|
package/README.md
CHANGED
|
@@ -452,31 +452,35 @@ npx homie-studio lounge mod <player id> # a mode
|
|
|
452
452
|
A studio sells items for its games (0.24.0; Stripe's own agent tools since 0.24.3) with **its own Stripe account**: the studio is the seller, money goes
|
|
453
453
|
straight from players to its Stripe, and homie.rocks never sees, holds or moves it (Homie takes no cut; no shared
|
|
454
454
|
currency; not a Connect platform). The studio chooses its prices, item kinds and optional spending and refund
|
|
455
|
-
settings, with no Homie ceiling.
|
|
456
|
-
|
|
457
|
-
The studio
|
|
455
|
+
settings, with no Homie ceiling. The default is open: guests can buy repeatedly without an age question,
|
|
456
|
+
from any page, with any wording. `protective` and `adults-only` are optional presets; neither scans content.
|
|
457
|
+
The studio chooses its settings in shop.json; [shop/SHOP.md](shop/SHOP.md) explains them.
|
|
458
458
|
|
|
459
459
|
```sh
|
|
460
460
|
npx homie-studio shop init --supporter # shop.json with a US$5 Supporter pack, and SELLING.md (the owner's plain words)
|
|
461
461
|
npx homie-studio shop check # the studio settings and provider requirements; every build checks them too
|
|
462
462
|
npx homie-studio shop catalog [--have <file>] # the items as Products in Stripe: the read, then the exact writes (Stripe's MCP)
|
|
463
|
-
npx homie-studio shop connect #
|
|
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
|
-
only OAuth or Agent-tagged keys; the shop's own key is a plain restricted key and is not affected.
|
|
468
|
+
Connect with `stripe_login` or `shop connect`: the owner approves Stripe's official browser page, sandbox first.
|
|
469
|
+
Where the CLI supplies a transferable legacy test key, the toolkit creates the webhook and installs both Worker
|
|
470
|
+
secrets privately. It reports the 90-day expiry; `shop connect --renew` obtains approval again. Current CLI OAuth
|
|
471
|
+
and live mode cannot export an independent Worker's key, so the result names that limitation. Only if the owner
|
|
472
|
+
chooses the fallback does `shop connect --manual` open the old local key page (`--manual --live` for live mode).
|
|
473
|
+
Never request a key in chat. Login is not a verified purchase; complete the test purchase, webhook and refund check.
|
|
475
474
|
|
|
476
|
-
|
|
477
|
-
|
|
475
|
+
Stripe's optional agent tools (`stripe agent setup`) provide MCP and skills for catalog work (`shop catalog`),
|
|
476
|
+
tax reads and sales questions. They are separate from the Worker's credentials. Never create a webhook through
|
|
477
|
+
MCP because its response contains a secret. From 2026-10-31 Stripe MCP accepts only OAuth or Agent-tagged keys;
|
|
478
|
+
that change does not affect the shop's ordinary restricted API key. [SHOP.md](shop/SHOP.md) describes the flow.
|
|
479
|
+
|
|
480
|
+
In a game: `createShop()` from `@homie-rocks/studio/shop` (`has`, `entitlements`, `on('change')`, `open`, `used`, `add`, `checkout`, `buy`); a
|
|
481
|
+
supporter's badge rides on their seat (`peer.badge`). Stripe Checkout (hosted), optional Stripe Tax, or Stripe Managed
|
|
478
482
|
Payments (Stripe as seller of record, 3.5% more) as one switch; a signed, idempotent webhook; refunds from the
|
|
479
|
-
office; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
|
|
483
|
+
office, per order or line; a dispute never touches the account; referral statements signed per referrer (homie.rocks is one more
|
|
480
484
|
referrer). The guide: `shop/SHOP.md`.
|
|
481
485
|
|
|
482
486
|
## Setup status
|
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/build.mjs
CHANGED
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
* site/dist/games.json { studio, games[], songs[], videos[], posts[], site, shop } from studio.json,
|
|
19
19
|
* game.json files, the music/ and videos/ manifests (media/MEDIA.md),
|
|
20
20
|
* posts/*.md, the studio's site/ folder (site/SITE.md) and shop.json (checked
|
|
21
|
-
* against
|
|
21
|
+
* against studio settings, Stripe constraints and arithmetic correctness)
|
|
22
22
|
* site/dist/games/<id>/_landing/... what the game's landing shows (hero footage, its cover, licence texts)
|
|
23
23
|
* site/dist/_site/ posts.json (the posts' HTML), the studio's own pages (site/pages)
|
|
24
24
|
* site/dist/_studio/codex/<id>/ each game's Game Codex (games/<id>/CODEX.md, lib/codex.mjs): served only to
|
|
@@ -489,7 +489,7 @@ export function orderGames(games, order, log = () => {}) {
|
|
|
489
489
|
export async function build(root, { only = null, log = () => {}, deploy = process.env.WORKERS_CI === '1', maps = false, types = false } = {}) {
|
|
490
490
|
const esbuild = await studioEsbuild(root);
|
|
491
491
|
const studio = readStudio(root);
|
|
492
|
-
// The shop first (shop/SHOP.md):
|
|
492
|
+
// The shop first (shop/SHOP.md): invalid shop settings stop the build before anything is built.
|
|
493
493
|
const shop = shopForBuild(root, { log });
|
|
494
494
|
const live = join(root, 'site', 'dist');
|
|
495
495
|
const games = listGames(root).filter((g) => !only || g.id === only);
|
|
@@ -677,7 +677,7 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
|
|
|
677
677
|
: studio.homie?.directory === false || studio.homie?.directory === null ? null : String(studio.homie?.directory ?? 'https://homie.rocks'),
|
|
678
678
|
// A copy of the public template that nobody has named yet shows the name typed in Cloudflare's form.
|
|
679
679
|
...(studio.template === true ? { template: true } : {}),
|
|
680
|
-
// studio.json "audience": "kids" (a studio
|
|
680
|
+
// studio.json "audience": "kids" (a studio audience label; shop policies are optional) or "teens"; and "referrals": false
|
|
681
681
|
// (this studio takes no referral statements as a referrer).
|
|
682
682
|
...(audienceOf(studio) !== 'general' ? { audience: audienceOf(studio) } : {}),
|
|
683
683
|
...(studio.referrals === false ? { referrals: false } : {}),
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -238,6 +238,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
238
238
|
const wide = wideRouteRefusal(readConfig(root), studio);
|
|
239
239
|
if (wide) return { ok: false, command: 'deploy', ...wide };
|
|
240
240
|
let routes = keptRoutes(root, studio);
|
|
241
|
+
const triggers = readConfig(root)?.triggers;
|
|
241
242
|
const who = whoami(root);
|
|
242
243
|
if (!who) {
|
|
243
244
|
if (cf.auth === 'stripe-projects') {
|
|
@@ -352,7 +353,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
352
353
|
const r2 = storage;
|
|
353
354
|
if (!r2) step('no storage (R2): the studio needs none to run; `homie-studio storage add` adds it for large media');
|
|
354
355
|
|
|
355
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes }));
|
|
356
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), routes, triggers }));
|
|
356
357
|
if (routes) step(`kept the studio's own route${routes.length === 1 ? '' : 's'} in wrangler.jsonc: ${routes.map((r) => r.pattern).join(', ')}`);
|
|
357
358
|
for (const added of ensureMigrations(root)) step(`added ${added} (${migrationWord(added)})`);
|
|
358
359
|
const migrate = w(['d1', 'migrations', 'apply', cf.d1, '--remote']);
|
|
@@ -360,7 +361,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
360
361
|
step('D1 migrations applied');
|
|
361
362
|
// Workers AI (0.17.0): bound only when a server's AI guides think with it.
|
|
362
363
|
if (needsWorkersAi(root, w, cf.d1)) {
|
|
363
|
-
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes }));
|
|
364
|
+
writeFileSync(configPath(root), wranglerConfig({ worker: cf.worker, name: studio.name, d1: cf.d1, d1Id: db.uuid, r2, layout: layoutOf(root), ai: true, routes, triggers }));
|
|
364
365
|
step('Workers AI bound (AI): a server\'s AI guides think with it and typed room chat is reviewed with it, each within its day\'s budget (free allocation: 10,000 neurons a day)');
|
|
365
366
|
}
|
|
366
367
|
|
package/lib/doctor.mjs
CHANGED
|
@@ -24,9 +24,9 @@
|
|
|
24
24
|
* GitHub optional a private backup, publishing by pull request, building from the Claude app
|
|
25
25
|
* ElevenLabs optional songs and game scores
|
|
26
26
|
* fal optional painted art and generated video
|
|
27
|
-
* Stripe for selling only in a studio with shop.json
|
|
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
|
@@ -29,7 +29,7 @@ import { PLAYERS_MIGRATION, PLAYERS_MIGRATION_FILE } from '../worker/players.mjs
|
|
|
29
29
|
import { OFFICE_MIGRATION, OFFICE_MIGRATION_FILE } from '../worker/office-schema.mjs';
|
|
30
30
|
import { SERVERS_MIGRATION, SERVERS_MIGRATION_FILE } from '../worker/servers.mjs';
|
|
31
31
|
import { CHAT_MIGRATION, CHAT_MIGRATION_FILE } from '../worker/chat-store.mjs';
|
|
32
|
-
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE } from '../worker/shop-store.mjs';
|
|
32
|
+
import { SHOP_MIGRATION, SHOP_MIGRATION_FILE, SHOP_RESERVATIONS, SHOP_RESERVATIONS_FILE, SHOP_STATEMENTS, SHOP_STATEMENTS_FILE, SHOP_LINES, SHOP_LINES_FILE } from '../worker/shop-store.mjs';
|
|
33
33
|
import { LOUNGE_MIGRATION, LOUNGE_MIGRATION_FILE } from '../worker/lounge-store.mjs';
|
|
34
34
|
import { themeFile } from './site.mjs';
|
|
35
35
|
import { rulesIndex } from './studio.mjs';
|
|
@@ -64,7 +64,7 @@ export function slugify(name) {
|
|
|
64
64
|
* No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
|
|
65
65
|
* the binding names and keep it linked.
|
|
66
66
|
*/
|
|
67
|
-
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null }) {
|
|
67
|
+
export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
|
|
68
68
|
const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
|
|
69
69
|
: { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
|
|
70
70
|
const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
|
|
@@ -85,6 +85,7 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
|
|
|
85
85
|
// every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
|
|
86
86
|
// (lib/routes.mjs). A studio without routes gets no key at all.
|
|
87
87
|
...(routes?.length ? { routes } : {}),
|
|
88
|
+
...(triggers ? { triggers } : {}),
|
|
88
89
|
assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
|
|
89
90
|
durable_objects: { bindings: rooms },
|
|
90
91
|
migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
|
|
@@ -433,41 +434,37 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
433
434
|
|
|
434
435
|
## Selling things (the shop)
|
|
435
436
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
-
|
|
450
|
-
|
|
451
|
-
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
account. \`shop\` says what is missing; \`shop orders\` lists orders (never a card or an email).
|
|
468
|
-
- **Referrals:** a \`?via=<host>\` link from another site (homie.rocks is one more referrer) is remembered on a new
|
|
469
|
-
player's first visit; a kept sale owes that referrer the rate in \`shop.json\` \`referrals\`. \`shop statements\`
|
|
470
|
-
signs them; the referrer invoices the studio. Nothing moves through Homie.
|
|
437
|
+
Your shop, your choices. The studio sells through its own Stripe account and is responsible for the law
|
|
438
|
+
where it sells and for Stripe's terms. Homie takes no cut.
|
|
439
|
+
|
|
440
|
+
- \`shop.json\` lists items, wording, kinds, prices, quantities and entitlements. Optional settings include
|
|
441
|
+
sale dates, tips, currency, \`automaticTax\`, refund windows, spending caps and referral terms.
|
|
442
|
+
- The default is open: guests can buy repeatedly, without age questions, from any page. Signing in keeps
|
|
443
|
+
guest purchases. \`shop init --supporter\` writes an example item without a policy, cap or refund window.
|
|
444
|
+
- Choose \`policy.preset: "protective"\` or \`"adults-only"\` to enable an account and age policy. Omitted
|
|
445
|
+
presets, including in existing files, use the open default. Individual overrides are in SHOP.md.
|
|
446
|
+
- \`createShop()\` from \`@homie-rocks/studio/shop\` supplies \`open\`, \`has\`, \`entitlements\`, \`used\`, and \`change\`.
|
|
447
|
+
\`shop.add(item, quantity)\` builds a cart; \`shop.checkout()\` pays; \`shop.buy(item)\` buys directly.
|
|
448
|
+
- With the fuller connection, a cart has independent lines and quantities. The office can refund a whole order or line. The optional
|
|
449
|
+
player refund window covers items; a tip is never taken back by the player.
|
|
450
|
+
- Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
|
|
451
|
+
new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
|
|
452
|
+
- \`shop check\` validates settings; \`stripe_login\` / \`shop connect\` starts Stripe browser approval.
|
|
453
|
+
Never ask a person for a key. Follow the result's next step: deploy first if needed, then rerun connect.
|
|
454
|
+
The default syncs Products, Prices, Payment Links and the webhook through the approved Stripe CLI.
|
|
455
|
+
The Worker stores only links and a signing secret; buying needs no API key or running AI.
|
|
456
|
+
Rerun \`shop connect\` after shop.json changes; use \`shop connect --renew\` if approval needs renewal.
|
|
457
|
+
Verify a test purchase, signed webhook, item grant, and a refund in Stripe or through the connected AI.
|
|
458
|
+
Never ask a person for a key. An existing Worker key selects the fuller cart/cap/refund path;
|
|
459
|
+
explain it only when those features are requested. Never silently choose \`--manual\`. Live only on request.
|
|
460
|
+
A lost webhook stays pending: ask Stripe to resend it. Free orders need no Stripe.
|
|
461
|
+
Never create secret-returning webhooks through conversation MCP tools; connect captures secrets privately.
|
|
462
|
+
Stripe's approval links belong to the owner. \`checkoutMinutes\` is optional (Stripe: 30 to 1440 minutes,
|
|
463
|
+
default 1440; values below 31 use 31 for transport margin); cancelling a named checkout, or replacing one at least a minute old, expires the open session on Stripe's confirmation.
|
|
464
|
+
- \`shop orders\` and \`/_studio/office/shop\` show sales. \`shop refund <order>\` asks the owner to confirm.
|
|
465
|
+
\`shop statements\` records optional referral shares; studios pay their referrers directly.
|
|
466
|
+
|
|
467
|
+
The guide is \`node_modules/@homie-rocks/studio/shop/SHOP.md\`.
|
|
471
468
|
|
|
472
469
|
## Continuing a build from the Claude app
|
|
473
470
|
|
|
@@ -730,6 +727,7 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
|
|
|
730
727
|
[`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
|
|
731
728
|
[`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
|
|
732
729
|
[`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
|
|
730
|
+
[`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
|
|
733
731
|
[`site/migrations/${LOUNGE_MIGRATION_FILE}`]: LOUNGE_MIGRATION,
|
|
734
732
|
'wrangler.jsonc': wranglerConfig({ worker, name, d1: studio.cloudflare.d1, r2: studio.cloudflare.r2, layout: 'root' }),
|
|
735
733
|
'.claude/skills/.gitkeep': '',
|
|
@@ -852,6 +850,14 @@ function ensureShopStatements(root) {
|
|
|
852
850
|
return `site/migrations/${SHOP_STATEMENTS_FILE}`;
|
|
853
851
|
}
|
|
854
852
|
|
|
853
|
+
function ensureShopLines(root) {
|
|
854
|
+
const file = join(root, 'site', 'migrations', SHOP_LINES_FILE);
|
|
855
|
+
if (existsSync(file)) return null;
|
|
856
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
857
|
+
writeFileSync(file, SHOP_LINES);
|
|
858
|
+
return `site/migrations/${SHOP_LINES_FILE}`;
|
|
859
|
+
}
|
|
860
|
+
|
|
855
861
|
/** Every migration the template owns that this studio lacks, added: the files written (deploy and dev say so). */
|
|
856
862
|
/** A studio made before 0.23.0 has no room chat tables: the owner's chat rules and players' reports. */
|
|
857
863
|
function ensureChatMigration(root) {
|
|
@@ -872,12 +878,12 @@ function ensureLoungeMigration(root) {
|
|
|
872
878
|
}
|
|
873
879
|
|
|
874
880
|
export function ensureMigrations(root) {
|
|
875
|
-
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root)].filter(Boolean);
|
|
881
|
+
return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root)].filter(Boolean);
|
|
876
882
|
}
|
|
877
883
|
|
|
878
884
|
/** What a migration file the template added is for, in a few words (deploy and dev say it). */
|
|
879
885
|
export function migrationWord(file) {
|
|
880
|
-
return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (
|
|
886
|
+
return /lounge/.test(file) ? 'the Lounge and kept chat: play nights, moderators, and what was said only where an owner turns history on' : /shop/.test(file) ? 'the shop: orders, what players own, refunds and referral books (paid sales wait until shop.json and the studio\'s Stripe connection are ready)' : /players/.test(file) ? 'player accounts and cloud saves' : /chat/.test(file) ? 'room chat: the owner\'s chat rules and players\' reports (never the chat itself)' : /servers/.test(file) ? 'servers and agent seats: room pools with their own rules, AI passes' : /office/.test(file) ? 'the back office: launch states, invites, the owner\'s controls' : 'the studio\'s own stats: counts, never tracks';
|
|
881
887
|
}
|
|
882
888
|
|
|
883
889
|
/**
|
package/lib/shop-catalog.mjs
CHANGED
|
@@ -31,11 +31,11 @@ export async function wantedProducts(shop, slug) {
|
|
|
31
31
|
return Promise.all((shop?.items ?? []).map(async (it) => {
|
|
32
32
|
const id = await productIdOf(slug, it.id);
|
|
33
33
|
return {
|
|
34
|
-
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: defaultTaxCode(it),
|
|
34
|
+
id, item: it.id, name: it.name, description: it.blurb ?? null, tax_code: it.taxCode ?? (shop.till === 'stripe-managed' ? defaultTaxCode(it) : null),
|
|
35
35
|
// Stripe metadata values: 500 characters. https://docs.stripe.com/metadata (fetched 2026-10-08).
|
|
36
36
|
metadata: { homie: MARK, homie_studio: String(slug), homie_item: it.id.length <= 500 ? it.id : id },
|
|
37
|
-
price: it.
|
|
38
|
-
shown: it.
|
|
37
|
+
price: it.price === 'choose' ? null : { currency: shop.currency, unit_amount: it.price, tax_behavior: 'exclusive' },
|
|
38
|
+
shown: it.price === 'choose' ? `pay what you want, ${money(it.min, shop.currency)}${it.max === null ? ' or more' : ` to ${money(it.max, shop.currency)}`}` : money(it.price, shop.currency),
|
|
39
39
|
};
|
|
40
40
|
}));
|
|
41
41
|
}
|
|
@@ -96,7 +96,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
|
|
|
96
96
|
if (!p) {
|
|
97
97
|
writes.push({
|
|
98
98
|
tool: 'stripe_api_write', method: 'POST', path: '/v1/products', why: `make ${w.name} (${w.shown})`,
|
|
99
|
-
params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), tax_code: w.tax_code, metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
|
|
99
|
+
params: { id: w.id, name: w.name, ...(w.description ? { description: w.description } : {}), ...(w.tax_code ? { tax_code: w.tax_code } : {}), metadata: meta, ...(w.price ? { default_price_data: w.price } : {}) },
|
|
100
100
|
});
|
|
101
101
|
continue;
|
|
102
102
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/** Reconcile through the owner's official Stripe CLI session; never export its credentials. */
|
|
2
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
3
|
+
import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
|
|
4
|
+
import { defaultTaxCode } from '../worker/shop-rules.mjs';
|
|
5
|
+
import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/shop-links.mjs';
|
|
6
|
+
import { HOOK_EVENTS } from './shop.mjs';
|
|
7
|
+
const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
|
|
8
|
+
const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
|
|
9
|
+
export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt, saveReceipt }) {
|
|
10
|
+
const list = async (path, params = {}) => {
|
|
11
|
+
const all = []; let cursor;
|
|
12
|
+
do {
|
|
13
|
+
const p = await api('GET', path, { ...params, limit: 100, ...(cursor ? { starting_after: cursor } : {}) });
|
|
14
|
+
if (!Array.isArray(p.data)) throw new Error('Incomplete Stripe list');
|
|
15
|
+
all.push(...p.data);
|
|
16
|
+
const next = p.has_more ? p.data.at(-1)?.id : null;
|
|
17
|
+
if (p.has_more && (!next || next === cursor)) throw new Error('Incomplete Stripe pagination');
|
|
18
|
+
cursor = next;
|
|
19
|
+
} while (cursor);
|
|
20
|
+
return all;
|
|
21
|
+
};
|
|
22
|
+
const secrets = await w(['secret', 'list']);
|
|
23
|
+
if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
|
|
24
|
+
const names = JSON.parse(secrets.stdout ?? secrets.out);
|
|
25
|
+
const has = (name) => names.some((s) => s.name === name);
|
|
26
|
+
const products = await list('/v1/products');
|
|
27
|
+
const prices = await list('/v1/prices');
|
|
28
|
+
const links = await list('/v1/payment_links');
|
|
29
|
+
const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
|
|
30
|
+
const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
|
|
31
|
+
const keepPrices = new Set(), keepLinks = new Set(), keepProducts = new Set();
|
|
32
|
+
for (const item of shop.items) {
|
|
33
|
+
const id = await productIdOf(slug, item.id);
|
|
34
|
+
keepProducts.add(id);
|
|
35
|
+
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
36
|
+
const fields = { name: item.name, description: item.blurb ?? '', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
|
|
37
|
+
let product = products.find((p) => p.id === id);
|
|
38
|
+
if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
|
|
39
|
+
else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
|
|
40
|
+
const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
|
|
41
|
+
? { custom_unit_amount: { enabled: true, minimum: item.min, ...(item.max !== null ? { maximum: item.max } : {}) } }
|
|
42
|
+
: { unit_amount: item.price }) };
|
|
43
|
+
const revision = hash(priceFields);
|
|
44
|
+
let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
|
|
45
|
+
if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
|
|
46
|
+
if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
|
|
47
|
+
keepPrices.add(price.id);
|
|
48
|
+
if ((product.default_price?.id ?? product.default_price) !== price.id) await api('POST', `/v1/products/${id}`, { default_price: price.id });
|
|
49
|
+
if (item.price === 0) continue; // Free orders never leave the studio.
|
|
50
|
+
const params = { line_items: [{ price: price.id, quantity: 1, ...(item.price !== 'choose' ? { adjustable_quantity: { enabled: shop.policy.repeatPurchases, minimum: 1, maximum: 999999 } } : {}) }],
|
|
51
|
+
after_completion: { type: 'redirect', redirect: { url: `${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}` } },
|
|
52
|
+
...(shop.till === 'stripe-managed' ? { managed_payments: { enabled: true } } : { automatic_tax: { enabled: shop.automaticTax } }),
|
|
53
|
+
payment_intent_data: { metadata },
|
|
54
|
+
};
|
|
55
|
+
const linkRevision = hash({ params, item, policy: shop.policy });
|
|
56
|
+
let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
|
|
57
|
+
if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
|
|
58
|
+
if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
|
|
59
|
+
if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
|
|
60
|
+
keepLinks.add(link.id);
|
|
61
|
+
config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
|
|
62
|
+
}
|
|
63
|
+
const endpoints = await list('/v1/webhook_endpoints');
|
|
64
|
+
const url = `${site}/api/shop/hook`;
|
|
65
|
+
const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events?.includes(event)));
|
|
66
|
+
let endpoint = current, secret;
|
|
67
|
+
if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
|
|
68
|
+
const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
|
|
69
|
+
saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
|
|
70
|
+
endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...HOOK_EVENTS], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
|
|
71
|
+
if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
|
|
72
|
+
secret = endpoint.secret;
|
|
73
|
+
}
|
|
74
|
+
const fingerprint = hash(config);
|
|
75
|
+
const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
|
|
76
|
+
if (!unchanged) {
|
|
77
|
+
const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
|
|
78
|
+
if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
|
|
79
|
+
// Explicitly running connect selects keyless; manual selects the existing keyed checkout.
|
|
80
|
+
if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
|
|
81
|
+
saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
|
|
82
|
+
}
|
|
83
|
+
// Retire only after the replacement configuration and signing secret are installed.
|
|
84
|
+
for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
|
|
85
|
+
for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
|
|
86
|
+
for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
|
|
87
|
+
for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
|
|
88
|
+
return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
|
|
89
|
+
message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
|
|
90
|
+
next: ['Verify a purchase, its signed webhook and item grant; refund in Stripe or ask your connected AI, then verify removal.'] };
|
|
91
|
+
}
|