@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/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
|
|
@@ -32,7 +33,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
32
33
|
import { askedFor, withKey } from './office.mjs';
|
|
33
34
|
import { runner } from './cloudflare.mjs';
|
|
34
35
|
import { listGames, readStudio, siteUrl } from './studio.mjs';
|
|
35
|
-
import {
|
|
36
|
+
import { SHOP_FILE, audienceOf, checkShop, defaultTaxCode, money } from '../worker/shop-rules.mjs';
|
|
36
37
|
import {
|
|
37
38
|
KEY_SHAPE, STRIPE_VERSION, StripeError, WEBHOOK_SECRET_SHAPE, createWebhookEndpoint, expireCheckoutSession, isPermissionError, listWebhookEndpoints, modeOf,
|
|
38
39
|
stripeCall, updateWebhookEndpoint,
|
|
@@ -43,7 +44,7 @@ const SELLING = join(HERE, '..', 'shop', 'SELLING.md');
|
|
|
43
44
|
/** The events the webhook endpoint listens to (the owner ticks these in Stripe). */
|
|
44
45
|
export const HOOK_EVENTS = Object.freeze([
|
|
45
46
|
'checkout.session.completed', 'checkout.session.async_payment_succeeded', 'checkout.session.async_payment_failed', 'checkout.session.expired',
|
|
46
|
-
'charge.refunded', 'refund.created', 'refund.updated', 'charge.dispute.created', 'charge.dispute.closed',
|
|
47
|
+
'charge.refunded', 'refund.created', 'refund.updated', 'refund.failed', 'charge.dispute.created', 'charge.dispute.closed',
|
|
47
48
|
]);
|
|
48
49
|
/** The restricted key's permissions, as Stripe's key page names them. Nothing else. */
|
|
49
50
|
export const KEY_PERMISSIONS = Object.freeze([
|
|
@@ -77,10 +78,10 @@ export function shopForBuild(root, { log = () => {} } = {}) {
|
|
|
77
78
|
export function shopCheck(root) {
|
|
78
79
|
const r = readShop(root);
|
|
79
80
|
if (r.absent) return { ok: true, command: 'shop check', absent: true, message: 'No shop.json: this studio sells nothing (homie-studio shop init writes one).' };
|
|
80
|
-
return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.
|
|
81
|
+
return { ok: r.ok, command: 'shop check', policy: r.shop?.policy, capPerPlayerMonth: r.shop?.capPerPlayerMonth, refundDays: r.shop?.refundDays, errors: r.errors, warnings: r.warnings, items: r.shop?.items.map((i) => `${i.id} (${i.kind}, ${i.price === 'choose' ? 'pay what you want' : money(i.price, r.shop.currency)})`) ?? [], till: r.shop?.till ?? null, why: r.ok ? undefined : `shop.json: ${r.errors.map((e) => `${e.at}: ${e.message}`).join('; ')}` };
|
|
81
82
|
}
|
|
82
83
|
|
|
83
|
-
/** The Supporter pack the kit suggests first:
|
|
84
|
+
/** The Supporter pack the kit suggests first: a badge on the profile and in rooms; the studio can replace or edit it. */
|
|
84
85
|
export function supporterItem({ price = 500, days = 365, name = 'Supporter' } = {}) {
|
|
85
86
|
return { id: 'supporter', kind: 'supporter', name, price, days, gives: ['badge:supporter'], badge: 'Supporter', blurb: 'A Supporter badge on your account and beside your name in rooms, for a year. It changes nothing about how any game plays.' };
|
|
86
87
|
}
|
|
@@ -91,7 +92,6 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
|
|
|
91
92
|
const studio = readStudio(root);
|
|
92
93
|
const shop = {
|
|
93
94
|
till: managed ? 'stripe-managed' : 'stripe', currency: String(currency).toLowerCase(),
|
|
94
|
-
policy: { preset: 'protective', ...POLICY_PRESETS.protective },
|
|
95
95
|
items: supporter ? [supporterItem({ price: Number(price) })] : [],
|
|
96
96
|
};
|
|
97
97
|
const r = checkShop(shop, { games: listGames(root).map((g) => g.id) });
|
|
@@ -99,7 +99,7 @@ export function shopInit(root, { supporter = false, currency = 'usd', price = 50
|
|
|
99
99
|
writeFileSync(file, `${JSON.stringify(shop, null, 2)}\n`);
|
|
100
100
|
const wrote = [SHOP_FILE];
|
|
101
101
|
if (!existsSync(join(root, 'SELLING.md')) && existsSync(SELLING)) { writeFileSync(join(root, 'SELLING.md'), readFileSync(SELLING, 'utf8')); wrote.push('SELLING.md'); }
|
|
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 (
|
|
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)'] };
|
|
103
103
|
}
|
|
104
104
|
|
|
105
105
|
/** The office's view of the live shop: open or what is missing, the last 30 days, links to Stripe. */
|
|
@@ -107,7 +107,10 @@ export async function shopStatus(root, { url } = {}) {
|
|
|
107
107
|
const r = await withKey(root, url, (call) => call('/_studio/api/shop'));
|
|
108
108
|
if (!r.ok) return { ok: false, command: 'shop', why: r.message ?? r.why ?? 'the studio did not answer' };
|
|
109
109
|
const local = readShop(root);
|
|
110
|
-
|
|
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 } };
|
|
111
114
|
}
|
|
112
115
|
|
|
113
116
|
export async function shopOrders(root, { url } = {}) {
|
|
@@ -162,8 +165,9 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
162
165
|
<style>body{font:16px/1.5 system-ui,sans-serif;max-width:40rem;margin:2.5rem auto;padding:0 1rem;color:#1b1b1b}h1{font-size:1.6rem}h2{font-size:1.1rem;margin-top:1.8rem}input[type=password]{width:100%;box-sizing:border-box;font:inherit;padding:.6rem;border:1px solid #999;border-radius:8px}label{display:block;margin:.8rem 0 .3rem;font-weight:600}fieldset{border:1px solid #ccc;border-radius:10px;padding:.6rem 1rem}fieldset label{font-weight:400;display:flex;gap:.6rem;align-items:flex-start}button{margin-top:1.2rem;font:inherit;font-weight:700;padding:.7rem 1.3rem;border:0;border-radius:8px;background:#1b1b1b;color:#fff}small{color:#555}code{font-size:.85em;background:#f2f2f2;padding:0 .25em;border-radius:4px}ol li,ul li{margin:.25rem 0}.box{background:#f6f6f2;border-radius:10px;padding:.8rem 1rem}details{margin-top:1rem}summary{cursor:pointer}</style>
|
|
163
166
|
<h1>Connect ${esc(studio)}'s shop to your Stripe</h1>
|
|
164
167
|
<p>Money goes straight from players to <b>your own Stripe account</b>. Homie never sees it and takes no cut. What you paste here goes from this page to Stripe (one check) and to your studio's Worker as a secret, and nowhere else: not to the chat, not to a file, not to a log.</p>
|
|
165
|
-
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
|
|
166
|
-
<h2>1. In Stripe:
|
|
168
|
+
<p class="box">${test ? '<b>Test mode first.</b> Use your Stripe sandbox or test mode (the key starts with <code>rk_test_</code> or <code>sk_test_</code>): nothing real is charged, and test cards work. Live keys only when you say the shop is ready to sell.' : '<b>Live mode.</b> Real cards, real money.'}</p>
|
|
169
|
+
<h2>1. In Stripe: a key for this shop</h2>
|
|
170
|
+
<p>You can use a restricted (<code>rk_</code>) or full secret (<code>sk_</code>) key. To choose a restricted key with just the permissions this shop uses:</p>
|
|
167
171
|
<ol><li>Open <a href="${dash}/apikeys" target="_blank" rel="noopener">Developers → API keys</a> and press <b>Create restricted key</b>. If Stripe asks what the key is for, choose your own integration, <b>not</b> "Authorizing agent access" (Stripe holds an agent key's refunds for a second approval).</li>
|
|
168
172
|
<li>Name it "${esc(studio)} shop" and give it exactly these permissions (leave everything else at None):<ul>${perms}</ul></li>
|
|
169
173
|
<li>Create it and copy the key (it starts with <code>rk_</code>).</li></ol>
|
|
@@ -171,7 +175,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
171
175
|
<p>With your key, this page makes the endpoint where Stripe tells your shop about payments (<code>${esc(hookUrlOf(site))}</code>, for ${HOOK_EVENTS.length} events), and its signing secret goes straight to your Worker. Nobody sees it. An older one this page made for the same address is turned off, never deleted.</p>
|
|
172
176
|
<form method="post" action="/key"><input type="hidden" name="n" value="${esc(nonce)}">
|
|
173
177
|
<h2>3. Paste the key here</h2>
|
|
174
|
-
<label for="key">
|
|
178
|
+
<label for="key">Stripe key</label><input id="key" name="key" type="password" autocomplete="off" placeholder="rk_test_…" required>
|
|
175
179
|
<details><summary>I made the webhook myself (or my key has no Webhook Endpoints permission)</summary>
|
|
176
180
|
<ol><li>Open <a href="${dash}/workbench/webhooks" target="_blank" rel="noopener">Webhooks</a> and press <b>Create an event destination</b> (Your account, a Webhook endpoint).</li>
|
|
177
181
|
<li>Endpoint URL: <code>${esc(hookUrlOf(site))}</code></li><li>Events: ${events}</li>
|
|
@@ -179,7 +183,7 @@ export function connectPage({ nonce, site, studio, till = 'stripe', test = true
|
|
|
179
183
|
<label for="hook">Webhook signing secret</label><input id="hook" name="hook" type="password" autocomplete="off" placeholder="whsec_…"></details>
|
|
180
184
|
<h2>4. Who is the seller?</h2>
|
|
181
185
|
<fieldset>
|
|
182
|
-
<label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Stripe Tax
|
|
186
|
+
<label><input type="radio" name="till" value="stripe"${till !== 'stripe-managed' ? ' checked' : ''}> <span><b>You are</b> (standard Stripe). Stripe takes its usual card fee (in the US 2.9% + 30¢ a sale; in Canada 2.9% + CA$0.30). Set automaticTax: true in shop.json to enable Stripe Tax: it works out and collects sales tax and VAT where you have told Stripe you are registered (0.5% a sale there). Registering and filing are yours, and some countries (the EU, the UK) expect a foreign seller to register from the first sale.</span></label>
|
|
183
187
|
<label><input type="radio" name="till" value="stripe-managed"${till === 'stripe-managed' ? ' checked' : ''}> <span><b>Stripe is</b> (Stripe Managed Payments). <b>3.5% more</b> a sale, on top of the card fee. Stripe becomes the seller of record: it registers for, collects, files and pays sales tax and VAT in 80+ countries, runs fraud checks, answers card disputes for you and handles buyers' payment questions. Statements read <code>LINK.COM*</code>. You still cover the money of a lost dispute, and Stripe may refund a buyer within 60 days. Turn it on in Stripe first (<a href="${dash}/settings/managed-payments" target="_blank" rel="noopener">Managed Payments</a>, after Stripe's eligibility review; Canada and the US are among the countries it serves).${test ? ' In test mode this page tries one test checkout with Managed Payments (expired at once) and says whether Stripe takes it.' : ''}</span></label>
|
|
184
188
|
</fieldset>
|
|
185
189
|
<button>Save to my Worker</button></form>
|
|
@@ -225,7 +229,7 @@ async function makeWebhook(env, site, { fetcher }) {
|
|
|
225
229
|
* (there is no API to read it). Never in live mode.
|
|
226
230
|
*/
|
|
227
231
|
async function probeManaged(env, shop, { fetcher }) {
|
|
228
|
-
const item = shop?.items?.find((i) => i.
|
|
232
|
+
const item = shop?.items?.find((i) => i.price !== 'choose') ?? null;
|
|
229
233
|
try {
|
|
230
234
|
const s = await stripeCall(env, 'POST', '/v1/checkout/sessions', {
|
|
231
235
|
mode: 'payment', success_url: 'https://example.com/homie-shop-check', cancel_url: 'https://example.com/homie-shop-check',
|
|
@@ -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' };
|
|
@@ -270,10 +274,9 @@ export async function shopConnect(root, { managed = null, live = false, log = ()
|
|
|
270
274
|
const say = ({ status, text }) => { res.writeHead(status, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' }); res.end(text); };
|
|
271
275
|
if (form.get('n') !== nonce || finished) { say(said(403, 'This page was used already.')); return; }
|
|
272
276
|
if (busy) { say(said(409, 'Still saving the last press. Wait a moment.')); return; }
|
|
273
|
-
if (
|
|
274
|
-
if (!KEY_SHAPE.test(key) || !/^rk_/.test(key)) { say(said(400, 'That does not look like a restricted key (rk_test_… or rk_live_…). Go back and try again.')); return; }
|
|
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; }
|
|
275
278
|
// Test mode unless the owner's AI ran it with --live on purpose: a live key on the test page is refused.
|
|
276
|
-
if (modeOf(key) !== (live ? 'live' : 'test')) { say(said(400, live ? 'This page takes a LIVE
|
|
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; }
|
|
277
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; }
|
|
278
281
|
busy = true;
|
|
279
282
|
try {
|
|
@@ -343,7 +346,8 @@ export function shopDisconnect(root) {
|
|
|
343
346
|
const w = runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
344
347
|
const a = w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' });
|
|
345
348
|
const b = w(['secret', 'delete', 'STRIPE_WEBHOOK_SECRET'], { input: 'y\n' });
|
|
346
|
-
|
|
349
|
+
const c = w(['secret', 'delete', 'STRIPE_SHOP_LINKS'], { input: 'y\n' });
|
|
350
|
+
return a.code === 0 || b.code === 0 || c.code === 0 ? { ok: true, command: 'shop disconnect', message: 'The Worker payment connection was removed. Ask your connected AI to deactivate existing Payment Links in Stripe too; already opened Stripe checkouts can still complete.' } : { ok: false, command: 'shop disconnect', why: a.out.trim().split('\n').slice(-2).join(' ') };
|
|
347
351
|
}
|
|
348
352
|
|
|
349
353
|
/** Lines for a person (the CLI's print). */
|
|
@@ -352,6 +356,7 @@ export function shopLines(r) {
|
|
|
352
356
|
if (r.command === 'shop') {
|
|
353
357
|
lines.push(r.ready ? `The shop is open${r.mode === 'test' ? ' in TEST MODE (no real money)' : ', live'} (till: ${r.till}).` : 'The shop is not selling yet:');
|
|
354
358
|
for (const m of r.missing ?? []) lines.push(` - ${m.words}`);
|
|
359
|
+
if (r.connection) lines.push(`Stripe test credentials expire ${r.connection.expiresAt}. Renew: ${r.connection.renew}`);
|
|
355
360
|
if (r.totals) lines.push(`Last 30 days: ${r.totals.sales} sales, ${money(r.totals.paid, r.totals.currency)} before tax and fees; ${r.totals.refunds} refunded; ${r.totals.disputes} disputed.`);
|
|
356
361
|
lines.push(`Webhook endpoint for Stripe: ${r.hook}`, `Payouts: ${r.stripe?.payouts}`);
|
|
357
362
|
if (r.local && !r.local.ok) lines.push(`shop.json here: ${r.local.errors.map((e) => e.message).join('; ')}`);
|
|
@@ -373,6 +378,9 @@ export function shopLines(r) {
|
|
|
373
378
|
if (r.sent) for (const s of r.sent) lines.push(`${s.via}: ${s.ok ? 'sent' : `not sent (${s.why ?? s.status})`}`);
|
|
374
379
|
else for (const s of r.statements ?? []) lines.push(`${s.statement.referrer}: due ${s.statement.totals ? money(s.statement.totals.due, s.statement.currency) : 'see first page'} (${s.statement.lines.length} line(s)), signed`);
|
|
375
380
|
if (!(r.sent ?? r.statements ?? []).length) lines.push(`No referral statements for ${r.period}.`);
|
|
376
|
-
} else lines.push(r.message ?? '');
|
|
381
|
+
} else { lines.push(r.message ?? r.why ?? (r.alreadyConnected ? 'Stripe test credentials are already installed.' : '')); for (const step of r.next ?? []) lines.push(step); if (r.fallback) lines.push(`Optional fallback, only if chosen: ${r.fallback}`); }
|
|
377
382
|
return lines;
|
|
378
383
|
}
|
|
384
|
+
|
|
385
|
+
// Public entry point: browser pairing by default; the local key page is explicitly opt-in.
|
|
386
|
+
export { shopConnect } from './stripe-connect.mjs';
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/** Stripe owns browser authorization. This module owns only secret transport and a non-secret receipt.
|
|
2
|
+
* See docs/stripe-connect-research.md: current CLI OAuth is NOT a Worker credential issuer. */
|
|
3
|
+
import { spawn } from 'node:child_process';
|
|
4
|
+
import { createHash } from 'node:crypto';
|
|
5
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { homedir } from 'node:os';
|
|
7
|
+
import { join } from 'node:path';
|
|
8
|
+
import { runner } from './cloudflare.mjs';
|
|
9
|
+
import { lockDeploy } from './deploy-state.mjs';
|
|
10
|
+
import { readStudio, siteUrl } from './studio.mjs';
|
|
11
|
+
import { hookUrlOf, readShop, shopConnectManual } from './shop.mjs';
|
|
12
|
+
import { syncPaymentLinks } from './shop-links.mjs';
|
|
13
|
+
import { LINKS_STRIPE_VERSION, fullerReason } from '../worker/shop-links.mjs';
|
|
14
|
+
import { formEncode } from '../worker/stripe.mjs';
|
|
15
|
+
|
|
16
|
+
const hash = (s) => createHash('sha256').update(s).digest('hex');
|
|
17
|
+
const failure = (needs, why, extra = {}) => ({ ok: false, command: 'shop connect', needs, why, ...extra });
|
|
18
|
+
const receiptHome = () => join(homedir(), '.config', 'homie', 'stripe');
|
|
19
|
+
const receiptDir = (root, storage) => join(storage, hash(realpathSync(root)).slice(0, 24));
|
|
20
|
+
|
|
21
|
+
/** Local expiry evidence only; no account query and no credentials in the result. */
|
|
22
|
+
export function stripeConnectionInfo(root, { storage = receiptHome(), now = Date.now } = {}) {
|
|
23
|
+
try {
|
|
24
|
+
const r = JSON.parse(readFileSync(join(receiptDir(root, storage), 'test.json'), 'utf8'));
|
|
25
|
+
if (r.url !== hookUrlOf(siteUrl(root)) || !Number.isFinite(Date.parse(r.expiresAt))) return null;
|
|
26
|
+
return { mode: 'test', expiresAt: r.expiresAt, expired: Date.parse(r.expiresAt) <= now(), renew: 'homie-studio shop connect --renew' };
|
|
27
|
+
} catch { return null; }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const json = (s) => { try { return JSON.parse(s); } catch { return null; } };
|
|
31
|
+
// This output stays private, including CLI config, completion, errors and API responses.
|
|
32
|
+
export function stripeProcess(args, { cwd, env = process.env } = {}) {
|
|
33
|
+
const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
|
|
34
|
+
for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
const p = spawn('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
37
|
+
let stdout = '', stderr = '', ended = false;
|
|
38
|
+
const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
|
|
39
|
+
const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
|
|
40
|
+
p.stdout.on('data', (b) => { stdout += b; if (stdout.length > 4_000_000) p.kill(); });
|
|
41
|
+
p.stderr.on('data', (b) => { stderr += b; if (stderr.length > 1_000_000) p.kill(); });
|
|
42
|
+
p.on('error', (e) => end({ code: 1, missing: e.code === 'ENOENT' }));
|
|
43
|
+
p.on('close', (code) => end({ code: code ?? 1, stdout, stderr }));
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function openBrowser(url) {
|
|
48
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
49
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
50
|
+
const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
51
|
+
p.on('error', () => {}); p.unref();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const stripeURL = (s) => {
|
|
55
|
+
try { const u = new URL(s); return u.protocol === 'https:' && ['dashboard.stripe.com', 'access.stripe.com'].includes(u.hostname) && !u.username && !u.password && !u.port; } catch { return false; }
|
|
56
|
+
};
|
|
57
|
+
/** Accept only Stripe's known login continuations. Never execute next_step as shell text. */
|
|
58
|
+
export function completionArgs(step) {
|
|
59
|
+
if (step === 'stripe login --complete-device') return ['login', '--complete-device'];
|
|
60
|
+
if (step === 'stripe login --complete-reauth') return ['login', '--complete-reauth'];
|
|
61
|
+
const m = /^stripe login --complete[ =](?:'([^']+)'|"([^"]+)"|(\S+))$/.exec(step ?? '');
|
|
62
|
+
const url = m && (m[1] || m[2] || m[3]);
|
|
63
|
+
return stripeURL(url) ? ['login', '--complete', url] : null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function shopConnect(root, options = {}) {
|
|
67
|
+
if (options.manual) {
|
|
68
|
+
const result = await shopConnectManual(root, options);
|
|
69
|
+
if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
|
|
70
|
+
return result;
|
|
71
|
+
}
|
|
72
|
+
const { live = false, renew = false, log = () => {}, exec = stripeProcess, open = openBrowser,
|
|
73
|
+
storage = receiptHome(), wrangler = null } = options;
|
|
74
|
+
const mode = live ? 'live' : 'test';
|
|
75
|
+
const checked = readShop(root);
|
|
76
|
+
if (!checked.ok || checked.absent) return failure('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
|
|
77
|
+
if (options.managed === true && checked.shop.till !== 'stripe-managed') return failure('shop-settings', 'For Managed Payments, set shop.json till to stripe-managed and deploy that choice before connecting. Stripe’s own eligibility and terms still apply.');
|
|
78
|
+
const id = hash(realpathSync(root)).slice(0, 24);
|
|
79
|
+
const profile = `homie-${id}`;
|
|
80
|
+
const dir = join(storage, id);
|
|
81
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
82
|
+
// Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
|
|
83
|
+
const lock = lockDeploy(dir);
|
|
84
|
+
if (!lock.ok) return failure('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
|
|
85
|
+
try {
|
|
86
|
+
const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
|
|
87
|
+
const installed = await cli(['--version']);
|
|
88
|
+
if (installed.code !== 0) return failure('stripe-cli', 'Install Stripe’s official CLI, then run shop connect again.', { next: ['npm install -g @stripe/cli@latest'] });
|
|
89
|
+
|
|
90
|
+
// A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
|
|
91
|
+
let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
92
|
+
if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) {
|
|
93
|
+
const start = await cli(['login', '--non-interactive', ...(renew ? ['--new-session'] : [])]);
|
|
94
|
+
const login = json(start.stdout);
|
|
95
|
+
const complete = completionArgs(login?.next_step);
|
|
96
|
+
if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
|
|
97
|
+
return failure('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.');
|
|
98
|
+
}
|
|
99
|
+
log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
|
|
100
|
+
open(login.browser_url);
|
|
101
|
+
const finished = await cli(complete);
|
|
102
|
+
if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.');
|
|
103
|
+
|
|
104
|
+
probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
105
|
+
if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) return failure('stripe-access', 'Stripe is signed in but cannot read Products in the requested mode. Review the account, sandbox and permissions in Stripe, then run shop connect --renew again.');
|
|
106
|
+
}
|
|
107
|
+
const site = siteUrl(root);
|
|
108
|
+
if (!site) return failure('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
|
|
109
|
+
let target;
|
|
110
|
+
try { target = new URL(site); } catch { return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
|
|
111
|
+
if (target.protocol !== 'https:' || target.username || target.password || /^(localhost|127\.|\[::1\])/.test(target.hostname)) return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.');
|
|
112
|
+
const api = async (method, path, params, idempotency) => {
|
|
113
|
+
const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
|
|
114
|
+
for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
|
|
115
|
+
const r = await cli(args);
|
|
116
|
+
const body = json(r.stdout);
|
|
117
|
+
if (r.code !== 0 || !body || body.error) throw new Error('Stripe API operation failed');
|
|
118
|
+
return body;
|
|
119
|
+
};
|
|
120
|
+
const reason = fullerReason(checked.shop);
|
|
121
|
+
if (reason) return failure('fuller-checkout', reason, { mode, connected: true });
|
|
122
|
+
const studio = readStudio(root);
|
|
123
|
+
const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
124
|
+
const receiptFile = join(dir, `${mode}.json`);
|
|
125
|
+
const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
|
|
126
|
+
const result = await syncPaymentLinks({ api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
|
|
127
|
+
saveReceipt: (record) => {
|
|
128
|
+
writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
|
|
129
|
+
renameSync(`${receiptFile}.tmp`, receiptFile);
|
|
130
|
+
} });
|
|
131
|
+
return { ...result, profile };
|
|
132
|
+
} catch {
|
|
133
|
+
return failure('stripe-setup', 'Stripe setup could not finish. Check the studio’s Stripe session and Products, Prices, Payment Links and Webhook Endpoints write permissions, and the Cloudflare login, then run shop connect again. No credentials were printed.');
|
|
134
|
+
} finally { lock.release(); }
|
|
135
|
+
}
|
|
@@ -1822,6 +1822,90 @@
|
|
|
1822
1822
|
}
|
|
1823
1823
|
}
|
|
1824
1824
|
}
|
|
1825
|
+
},
|
|
1826
|
+
"0.34.0": {
|
|
1827
|
+
"files": {
|
|
1828
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1829
|
+
"README.md": "684b3cca8001673c",
|
|
1830
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1831
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1832
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1833
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1834
|
+
"posts/README.md": "37841aae476bd540",
|
|
1835
|
+
"site/README.md": "732894273a32dc3f",
|
|
1836
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1837
|
+
},
|
|
1838
|
+
"sections": {
|
|
1839
|
+
"AGENTS.md": {
|
|
1840
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1841
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1842
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
1843
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1844
|
+
"## Making games": "2da7fa74c8884834",
|
|
1845
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1846
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1847
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1848
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1849
|
+
"## Selling things (the shop)": [
|
|
1850
|
+
"57c04f6526e16890",
|
|
1851
|
+
"6c30b932b0783495",
|
|
1852
|
+
"9e020d69dd6a954e"
|
|
1853
|
+
],
|
|
1854
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1855
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1856
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1857
|
+
"## Beta": "9d02638207a26e73"
|
|
1858
|
+
}
|
|
1859
|
+
},
|
|
1860
|
+
"named": {
|
|
1861
|
+
"Homie Arcade": {
|
|
1862
|
+
"sections": {
|
|
1863
|
+
"AGENTS.md": {
|
|
1864
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1869
|
+
},
|
|
1870
|
+
"0.35.0": {
|
|
1871
|
+
"files": {
|
|
1872
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1873
|
+
"README.md": "684b3cca8001673c",
|
|
1874
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1875
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1876
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1877
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1878
|
+
"posts/README.md": "37841aae476bd540",
|
|
1879
|
+
"site/README.md": "732894273a32dc3f",
|
|
1880
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1881
|
+
},
|
|
1882
|
+
"sections": {
|
|
1883
|
+
"AGENTS.md": {
|
|
1884
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1885
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1886
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
1887
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1888
|
+
"## Making games": "2da7fa74c8884834",
|
|
1889
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1890
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1891
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1892
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1893
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
1894
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1895
|
+
"## Rules": "2e6b29afb85dcf7e",
|
|
1896
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
1897
|
+
"## Beta": "9d02638207a26e73"
|
|
1898
|
+
}
|
|
1899
|
+
},
|
|
1900
|
+
"named": {
|
|
1901
|
+
"Homie Arcade": {
|
|
1902
|
+
"sections": {
|
|
1903
|
+
"AGENTS.md": {
|
|
1904
|
+
"## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
|
|
1905
|
+
}
|
|
1906
|
+
}
|
|
1907
|
+
}
|
|
1908
|
+
}
|
|
1825
1909
|
}
|
|
1826
1910
|
}
|
|
1827
1911
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/shop/SELLING.md
CHANGED
|
@@ -1,97 +1,21 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Your shop, your choices
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
unsure about.
|
|
3
|
+
You sell with your own Stripe account. Money goes directly to you; Homie takes no cut.
|
|
4
|
+
The studio is the seller and is responsible for the law where it sells and for Stripe's terms.
|
|
6
5
|
|
|
7
|
-
|
|
6
|
+
Your `shop.json` chooses items, wording, prices, currency, tax integration, sale dates, entitlements,
|
|
7
|
+
referral terms, optional spending caps and optional refund windows. Nothing requires an account or age
|
|
8
|
+
question by default. Guests can buy repeatedly and keep their purchases when signing in later.
|
|
8
9
|
|
|
9
|
-
You
|
|
10
|
-
|
|
11
|
-
requests, you deal with card disputes, and the tax on your sales is yours to handle.
|
|
10
|
+
You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
|
|
11
|
+
Without a preset the shop is open. SHOP.md describes each setting and the game cart API.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
With the fuller checkout connection, a cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
|
|
14
|
+
A player can refund items within a window you set; a tip stays given unless you refund it.
|
|
15
|
+
Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
|
|
16
|
+
Managed Payments is another selectable integration. Your office links Stripe's own pages.
|
|
14
17
|
|
|
15
|
-
|
|
16
|
-
|---|---|---|---|
|
|
17
|
-
| Prices, what you sell | yes | | |
|
|
18
|
-
| Taking the card, the receipt email | | yes | |
|
|
19
|
-
| Refunds | yes: one tap in your office | carries them out | |
|
|
20
|
-
| Card disputes (chargebacks) | yes: you answer them in Stripe and carry the loss if you lose | runs the process | |
|
|
21
|
-
| Sales tax and VAT | yes, with Stripe Tax's help (or Managed Payments, below) | calculates and collects | |
|
|
22
|
-
| Your players' data | yes: you are responsible for it | keeps the card and email | stores nothing |
|
|
23
|
-
| Who may buy | your editable shop policy; protective defaults | provider terms | supplies presets |
|
|
18
|
+
`homie-studio shop connect` opens Stripe's approval page if needed, then your AI syncs your products, prices, payment links and webhook. You never handle a key. The shop sends buyers to Stripe, where they confirm quantity or choose a tip; your Worker holds only the webhook signing secret and link settings. Buy different items separately. Refund in Stripe or ask your connected AI; signed events update your shop. A missing payment stays pending until Stripe resends its event. Free orders need no Stripe.
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
- A player can refund an **unused** item themselves within the optional refund window you set from their
|
|
28
|
-
account page. Set `policy.refundUsedItems` to include used items. Without a window, buyers ask you for a refund.
|
|
29
|
-
Fewer people turn to card disputes when a refund is easy.
|
|
30
|
-
- Anything else, you refund with **one tap** on your office's shop page. Your AI can only *ask* you to refund; it
|
|
31
|
-
can never do it by itself.
|
|
32
|
-
- Stripe does not give back its processing fee on a refund. That is the cost of a happy player.
|
|
33
|
-
|
|
34
|
-
## Disputes (chargebacks)
|
|
35
|
-
|
|
36
|
-
- A buyer's bank can reverse a payment. Stripe charges a dispute fee (about US$15) whether you win or lose, and
|
|
37
|
-
you lose the sale's money if the bank decides for the buyer. On a US$5 item one dispute costs more than the
|
|
38
|
-
sale, so consider your costs when setting prices. Homie imposes no price or earnings ceiling.
|
|
39
|
-
- **A dispute never deletes or locks the player's account.** While it is open nothing changes. If you lose it,
|
|
40
|
-
that one item is taken back, as a refund would.
|
|
41
|
-
|
|
42
|
-
## Tax
|
|
43
|
-
|
|
44
|
-
You choose who the seller is when you connect your Stripe (`homie-studio shop connect` shows both):
|
|
45
|
-
|
|
46
|
-
- **You are the seller** (`"till": "stripe"`): Stripe Tax is on. It works out and collects tax **only where you
|
|
47
|
-
have told Stripe you are registered**, and it does not file your returns. Some places expect a foreign seller
|
|
48
|
-
of digital goods to register from the first sale (the EU and the UK do). Cheapest; the most work.
|
|
49
|
-
- **Stripe is the seller** (`"till": "stripe-managed"`, Stripe Managed Payments): **3.5% more** a sale. Stripe's
|
|
50
|
-
Link, LLC becomes the seller of record and registers, collects, files and pays sales tax and VAT in 80+
|
|
51
|
-
countries, runs fraud checks and answers disputes for you. You still carry the money of a lost dispute, and
|
|
52
|
-
Stripe may refund a buyer within 60 days. You turn it on in Stripe first (after Stripe's eligibility review).
|
|
53
|
-
|
|
54
|
-
## Kids
|
|
55
|
-
|
|
56
|
-
Your shop starts with the `protective` preset in shop.json. It requires an account and a neutral age question,
|
|
57
|
-
blocks purchases under 13, uses a parent's checkout for teens, closes shops on kids servers and kids studios,
|
|
58
|
-
hides advantages on beginner and kids servers, refuses paid randomness, countdowns and virtual-currency items,
|
|
59
|
-
and sends TV shoppers to a phone. Existing shops without a policy keep these protections.
|
|
60
|
-
|
|
61
|
-
These are **your settings**: read and edit `policy` in shop.json. `adults-only` also blocks teens;
|
|
62
|
-
`custom` enables all policies. You can override individual rules without changing the others. SHOP.md lists
|
|
63
|
-
every setting. No spending cap or refund window applies unless you set one; existing written values stay yours.
|
|
64
|
-
|
|
65
|
-
As the seller, you are responsible for the law and your payment provider's terms wherever you sell, including
|
|
66
|
-
rules protecting children. Choosing a preset does not establish compliance. This is information, not legal advice.
|
|
67
|
-
|
|
68
|
-
## Referrals
|
|
69
|
-
|
|
70
|
-
If another studio (or homie.rocks, or a creator) sends you a new player who buys something, you owe them the
|
|
71
|
-
referral share you published in `shop.json` (10% if you kept the suggestion; 0 if you turn it off). Your office
|
|
72
|
-
shows what you owe and to whom, and signs a monthly statement for each. **They invoice you, and you pay them
|
|
73
|
-
yourself.** Nothing moves through Homie.
|
|
74
|
-
|
|
75
|
-
## What you do, and what your AI does
|
|
76
|
-
|
|
77
|
-
Your AI sets the shop up with Stripe's own tools for AI (Stripe's agent plugin: its MCP server and skills). It
|
|
78
|
-
makes your products in Stripe, checks your tax settings, makes the webhook, and answers "how are sales?" by reading
|
|
79
|
-
your Stripe. What stays yours, in Stripe's own pages:
|
|
80
|
-
|
|
81
|
-
- making the Stripe account, and later activating it (your business details, bank account and identity checks);
|
|
82
|
-
- one sign-in page that lets your AI use Stripe's tools: give it your **sandbox** (test) first;
|
|
83
|
-
- one restricted key you make in Stripe and paste into a page on your own computer (never into a chat);
|
|
84
|
-
- approving anything Stripe asks you to confirm (a refund your AI asked Stripe for, for example), and every refund
|
|
85
|
-
in your office.
|
|
86
|
-
|
|
87
|
-
If your AI's app cannot use Stripe's sign-in page, Stripe offers an "Agent" key for it instead. From 31 October 2026
|
|
88
|
-
Stripe's tools for AI accept only those (or the sign-in page). The shop's own key is a different, ordinary
|
|
89
|
-
restricted key; never make that one an Agent key, or Stripe will hold every refund for a second approval.
|
|
90
|
-
|
|
91
|
-
## Before the first real sale
|
|
92
|
-
|
|
93
|
-
1. Make (or pick) your Stripe account, verify it, and add a bank account.
|
|
94
|
-
2. Test everything in **test mode** first: buy your own item with a Stripe test card, refund it from the office.
|
|
95
|
-
3. Fill in your public business details in Stripe (name, support email, the refund page `/shop/refunds/`).
|
|
96
|
-
4. Talk to an accountant about where you must register for tax, or choose Managed Payments.
|
|
97
|
-
5. Then, and only then, connect a live key (`homie-studio shop connect --live`).
|
|
20
|
+
The fuller connection adds combined carts, spending limits, custom checkout expiry, automatic recovery and refunds in the office. An existing Worker key selects it; your AI explains it only if you ask for those features. The optional manual setup remains available, but is not the default.
|
|
21
|
+
The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
|