@homie-rocks/studio 0.34.0 → 0.35.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +25 -1
- package/README.md +12 -8
- package/bin/homie-studio.mjs +4 -4
- package/lib/doctor.mjs +11 -11
- package/lib/mcp-tools.mjs +15 -0
- package/lib/mcp.mjs +1 -0
- package/lib/scaffold.mjs +12 -5
- package/lib/shop-catalog.mjs +20 -1
- package/lib/shop-links.mjs +93 -0
- package/lib/shop.mjs +31 -16
- package/lib/stripe-connect.mjs +146 -0
- package/lib/template-history.json +85 -1
- package/package.json +1 -1
- package/shop/SELLING.md +4 -2
- package/shop/SHOP.md +55 -17
- package/worker/shop-links.mjs +46 -0
- package/worker/shop-page.mjs +5 -3
- package/worker/shop.mjs +106 -31
- package/worker/stripe.mjs +3 -3
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,146 @@
|
|
|
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, redactStripe } 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
|
+
// Stripe CLI also prints login failures on stderr (stripe/stripe-cli pkg/cmd/root.go).
|
|
32
|
+
// Never fall back to stdout: successful login output can contain credentials.
|
|
33
|
+
const stripeReason = (result, call) => {
|
|
34
|
+
const message = (json(result?.stdout)?.error ?? json(result?.stderr)?.error)?.message
|
|
35
|
+
?? (result?.code !== 0 ? result?.stderr?.trim() : null);
|
|
36
|
+
return typeof message === 'string' ? ` Stripe said: “${redactStripe(message).slice(0, 400)}” (${call}).` : '';
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
// This output stays private, including CLI config, completion, errors and API responses.
|
|
40
|
+
export function stripeProcess(args, { cwd, env = process.env } = {}) {
|
|
41
|
+
const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
|
|
42
|
+
for (const k of Object.keys(childEnv)) if (k.startsWith('STRIPE_')) delete childEnv[k];
|
|
43
|
+
return new Promise((resolve) => {
|
|
44
|
+
const p = spawn('stripe', args, { cwd, env: childEnv, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
45
|
+
let stdout = '', stderr = '', ended = false;
|
|
46
|
+
const end = (r) => { if (!ended) { ended = true; clearTimeout(timer); resolve(r); } };
|
|
47
|
+
const timer = setTimeout(() => { p.kill(); end({ code: 1 }); }, 10 * 60_000);
|
|
48
|
+
p.stdout.on('data', (b) => { stdout += b; if (stdout.length > 4_000_000) p.kill(); });
|
|
49
|
+
p.stderr.on('data', (b) => { stderr += b; if (stderr.length > 1_000_000) p.kill(); });
|
|
50
|
+
p.on('error', (e) => end({ code: 1, missing: e.code === 'ENOENT' }));
|
|
51
|
+
p.on('close', (code) => end({ code: code ?? 1, stdout, stderr }));
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function openBrowser(url) {
|
|
56
|
+
const cmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'rundll32' : 'xdg-open';
|
|
57
|
+
const args = process.platform === 'win32' ? ['url.dll,FileProtocolHandler', url] : [url];
|
|
58
|
+
const p = spawn(cmd, args, { stdio: 'ignore', detached: true });
|
|
59
|
+
p.on('error', () => {}); p.unref();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const stripeURL = (s) => {
|
|
63
|
+
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; }
|
|
64
|
+
};
|
|
65
|
+
/** Accept only Stripe's known login continuations. Never execute next_step as shell text. */
|
|
66
|
+
export function completionArgs(step) {
|
|
67
|
+
if (step === 'stripe login --complete-device') return ['login', '--complete-device'];
|
|
68
|
+
if (step === 'stripe login --complete-reauth') return ['login', '--complete-reauth'];
|
|
69
|
+
const m = /^stripe login --complete[ =](?:'([^']+)'|"([^"]+)"|(\S+))$/.exec(step ?? '');
|
|
70
|
+
const url = m && (m[1] || m[2] || m[3]);
|
|
71
|
+
return stripeURL(url) ? ['login', '--complete', url] : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function shopConnect(root, options = {}) {
|
|
75
|
+
if (options.manual) {
|
|
76
|
+
const result = await shopConnectManual(root, options);
|
|
77
|
+
if (result.ok) rmSync(join(receiptDir(root, options.storage ?? receiptHome()), 'test.json'), { force: true });
|
|
78
|
+
return result;
|
|
79
|
+
}
|
|
80
|
+
const { live = false, renew = false, log = () => {}, exec = stripeProcess, open = openBrowser,
|
|
81
|
+
storage = receiptHome(), wrangler = null } = options;
|
|
82
|
+
const mode = live ? 'live' : 'test';
|
|
83
|
+
const checked = readShop(root);
|
|
84
|
+
if (!checked.ok || checked.absent) return failure('shop', 'Create and check shop.json first: homie-studio shop init, then shop check.');
|
|
85
|
+
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.');
|
|
86
|
+
const id = hash(realpathSync(root)).slice(0, 24);
|
|
87
|
+
const profile = `homie-${id}`;
|
|
88
|
+
const dir = join(storage, id);
|
|
89
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
90
|
+
// Reuse the toolkit’s process-aware lock, in the private receipt folder. Dead processes do not strand setup.
|
|
91
|
+
const lock = lockDeploy(dir);
|
|
92
|
+
if (!lock.ok) return failure('busy', 'Stripe connection is already running for this studio. Wait for that command to finish.');
|
|
93
|
+
try {
|
|
94
|
+
const cli = (args) => exec(['--project-name', profile, ...args], { cwd: root });
|
|
95
|
+
const installed = await cli(['--version']);
|
|
96
|
+
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'] });
|
|
97
|
+
|
|
98
|
+
// A read both checks existing credentials and lets Stripe refresh its OAuth session itself.
|
|
99
|
+
let probe = renew ? { code: 1 } : await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
100
|
+
if (probe.code !== 0 || !Array.isArray(json(probe.stdout)?.data)) {
|
|
101
|
+
const start = await cli(['login', '--non-interactive', ...(renew ? ['--new-session'] : [])]);
|
|
102
|
+
const login = json(start.stdout);
|
|
103
|
+
const complete = completionArgs(login?.next_step);
|
|
104
|
+
if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
|
|
105
|
+
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.' + stripeReason(start, 'login'));
|
|
106
|
+
}
|
|
107
|
+
log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
|
|
108
|
+
open(login.browser_url);
|
|
109
|
+
const finished = await cli(complete);
|
|
110
|
+
if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
|
|
111
|
+
|
|
112
|
+
probe = await cli(['get', '/v1/products', '-d', 'limit=1', ...(live ? ['--live'] : [])]);
|
|
113
|
+
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.' + stripeReason(probe, 'GET /v1/products'));
|
|
114
|
+
}
|
|
115
|
+
const site = siteUrl(root);
|
|
116
|
+
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 });
|
|
117
|
+
let target;
|
|
118
|
+
try { target = new URL(site); } catch { return failure('deploy', 'Deploy the studio to a public HTTPS address, then run shop connect again.'); }
|
|
119
|
+
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.');
|
|
120
|
+
const api = async (method, path, params, idempotency) => {
|
|
121
|
+
const args = [method.toLowerCase(), path, '--stripe-version', LINKS_STRIPE_VERSION, ...(live ? ['--live'] : []), ...(idempotency ? ['--idempotency', idempotency] : [])];
|
|
122
|
+
for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
|
|
123
|
+
const r = await cli(args);
|
|
124
|
+
const body = json(r.stdout);
|
|
125
|
+
if (r.code !== 0 || !body || body.error) {
|
|
126
|
+
throw Object.assign(new Error('Stripe API operation failed'), { stripeReason: stripeReason(r, `${method} ${path.replace(/\/[^/]*_[A-Za-z0-9]+$/, '/…')}`) });
|
|
127
|
+
}
|
|
128
|
+
return body;
|
|
129
|
+
};
|
|
130
|
+
const reason = fullerReason(checked.shop);
|
|
131
|
+
if (reason) return failure('fuller-checkout', reason, { mode, connected: true });
|
|
132
|
+
const studio = readStudio(root);
|
|
133
|
+
const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
|
|
134
|
+
const receiptFile = join(dir, `${mode}.json`);
|
|
135
|
+
const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
|
|
136
|
+
const result = await syncPaymentLinks({ api, shop: checked.shop, slug: studio.slug, site: target.origin, mode, w, receipt,
|
|
137
|
+
saveReceipt: (record) => {
|
|
138
|
+
writeFileSync(`${receiptFile}.tmp`, JSON.stringify(record), { mode: 0o600 });
|
|
139
|
+
renameSync(`${receiptFile}.tmp`, receiptFile);
|
|
140
|
+
} });
|
|
141
|
+
return { ...result, profile };
|
|
142
|
+
} catch (e) {
|
|
143
|
+
const said = e?.stripeReason ?? '';
|
|
144
|
+
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.' + said);
|
|
145
|
+
} finally { lock.release(); }
|
|
146
|
+
}
|
|
@@ -1846,7 +1846,91 @@
|
|
|
1846
1846
|
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1847
1847
|
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1848
1848
|
"## Servers and AI seats": "98d08705087a33ec",
|
|
1849
|
-
"## Selling things (the shop)":
|
|
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
|
+
}
|
|
1909
|
+
},
|
|
1910
|
+
"0.35.1": {
|
|
1911
|
+
"files": {
|
|
1912
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
1913
|
+
"README.md": "684b3cca8001673c",
|
|
1914
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
1915
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
1916
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
1917
|
+
"videos/README.md": "16babc3437d5545b",
|
|
1918
|
+
"posts/README.md": "37841aae476bd540",
|
|
1919
|
+
"site/README.md": "732894273a32dc3f",
|
|
1920
|
+
"site/src/worker.mjs": "fc90f3609cad18ba"
|
|
1921
|
+
},
|
|
1922
|
+
"sections": {
|
|
1923
|
+
"AGENTS.md": {
|
|
1924
|
+
"(intro)": "214aa46eb3a895d5",
|
|
1925
|
+
"## Layout": "8baf49e4291e7e88",
|
|
1926
|
+
"## Commands (all through the pinned CLI in node_modules)": "a7f290579585368c",
|
|
1927
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
1928
|
+
"## Making games": "2da7fa74c8884834",
|
|
1929
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
1930
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
1931
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
1932
|
+
"## Servers and AI seats": "98d08705087a33ec",
|
|
1933
|
+
"## Selling things (the shop)": "9e020d69dd6a954e",
|
|
1850
1934
|
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
1851
1935
|
"## Rules": "2e6b29afb85dcf7e",
|
|
1852
1936
|
"## Telling Homie": "80b46fdd9e578db8",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.1",
|
|
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
|
@@ -10,10 +10,12 @@ question by default. Guests can buy repeatedly and keep their purchases when sig
|
|
|
10
10
|
You can choose `policy.preset: "protective"` or `"adults-only"`, or set individual options.
|
|
11
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
14
|
A player can refund items within a window you set; a tip stays given unless you refund it.
|
|
15
15
|
Stripe handles payments, receipts, payment limits, held refunds and disputes. Stripe Tax is optional;
|
|
16
16
|
Managed Payments is another selectable integration. Your office links Stripe's own pages.
|
|
17
17
|
|
|
18
|
-
`homie-studio shop connect`
|
|
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.
|
|
19
|
+
|
|
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.
|
|
19
21
|
The scaffold has no policy, cap or refund window. Test mode is available before choosing live mode.
|
package/shop/SHOP.md
CHANGED
|
@@ -104,21 +104,59 @@ One order stores every line and its item snapshot. `/api/shop/mine` lists the li
|
|
|
104
104
|
|
|
105
105
|
`homie-studio shop init --supporter` writes an example item and `SELLING.md`, without a policy, cap or refund
|
|
106
106
|
window. `shop check` validates settings; the build uses the same validation.
|
|
107
|
-
`shop connect`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
107
|
+
`shop connect` (MCP `stripe_login`) starts Stripe's official browser approval, sandbox/test first.
|
|
108
|
+
If Stripe CLI is missing, the result gives `npm install -g @stripe/cli@latest` for the AI to run.
|
|
109
|
+
The person never supplies a key to the AI. A studio-specific CLI profile avoids borrowing another studio's login.
|
|
110
|
+
If the studio is not deployed yet, approval is retained: deploy with `studio_deploy` (and
|
|
111
|
+
`cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
|
|
112
|
+
|
|
113
|
+
**The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
|
|
114
|
+
Payment Link for each paid item. Tip Prices use custom amounts; free orders stay local. It creates
|
|
115
|
+
an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
|
|
116
|
+
and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
|
|
117
|
+
API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
|
|
118
|
+
Price versions, changed links get new versions, and removed objects are archived. Old paid orders
|
|
119
|
+
keep their frozen item definitions. Receipts contain no credentials. Unchanged runs create nothing.
|
|
120
|
+
|
|
121
|
+
With an existing account and deployed studio, the person approves Stripe CLI once; an already
|
|
122
|
+
connected profile needs zero new approvals. An MCP-only grant is separate and is never extracted.
|
|
123
|
+
The AI installs tooling, creates/syncs objects and stores the secret. New account verification,
|
|
124
|
+
activation, administrator access settings and Cloudflare authorization are separate prerequisites.
|
|
125
|
+
`shop connect --live` requires the owner's go-live request. `--renew` retries browser authorization.
|
|
126
|
+
Never request a key from the person for the default flow.
|
|
127
|
+
|
|
128
|
+
At purchase time, the Worker saves an opaque order reference and sends it as the link's
|
|
129
|
+
`client_reference_id`. The buyer confirms quantity or tip amount on Stripe. A signed snapshot
|
|
130
|
+
session must match the frozen link, revision, currency and allowed amount before granting. Reusing
|
|
131
|
+
a link creates a distinct order for each Session. Returns without a webhook never grant goods.
|
|
132
|
+
Refunds and disputes use durable financial snapshots in the existing meta table, without buyer/card
|
|
133
|
+
data. They tolerate retries and arrivals before the payment event. Fully successful refunds revoke
|
|
134
|
+
items; failed refunds restore the financial booking. A lost dispute revokes goods, never the account.
|
|
135
|
+
|
|
136
|
+
| Need | Keyless behavior / fuller connection |
|
|
137
|
+
|---|---|
|
|
138
|
+
| Several different items in one payment | Buy separately. Stripe supports static multi-item links (up to 20 fixed-price lines), but they are not an arbitrary buyer cart. Dynamic carts use the keyed path. |
|
|
139
|
+
| Reserve an optional spending allowance | Requires keyed Checkout; a reusable public link cannot atomically reserve this studio's local cap. No silent weakening of a configured cap. |
|
|
140
|
+
| Expire an abandoned session | Stripe controls the lifetime; deactivating a link prevents new sessions, not existing sessions. Custom expiry/cancellation requires the keyed path. |
|
|
141
|
+
| Refund in one tap / player self-refund | Owner uses Stripe Dashboard or asks their Stripe-connected AI. Signed events update books. Office shows guidance instead of a paid-order refund button. Free refunds remain local. |
|
|
142
|
+
| Lost webhook | Remains pending. Stripe retries; the AI can use `stripe events resend <event> --webhook-endpoint <endpoint>` (within Stripe's resend window). No grant from a browser return. Automatic provider-read reconciliation needs a key. |
|
|
143
|
+
| Free orders | Local, including free carts; no Stripe account, API key or webhook secret required. |
|
|
144
|
+
| Exact local eligibility/sale/repeat enforcement | Local checks run before redirect, but a public link can be reused. Sync deactivates removed links; strict pre-payment enforcement of previously issued links requires keyed sessions. |
|
|
145
|
+
| Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
|
|
146
|
+
| Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
|
|
147
|
+
|
|
148
|
+
An existing `STRIPE_KEY` selects the fuller path automatically. Only if requested, explain:
|
|
149
|
+
“The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
|
|
150
|
+
The explicit `shop connect --manual` path remains available, including `--live`, but requires the
|
|
151
|
+
owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
|
|
152
|
+
Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
|
|
153
|
+
When switching to spending restrictions, the AI must deactivate previously issued Payment Links through
|
|
154
|
+
the approved CLI and resolve in-flight checkouts before relying on those restrictions; switching credentials
|
|
155
|
+
does not cancel old Stripe sessions. The runtime OAuth finding and sources are in `docs/stripe-connect-research.md` in the source checkout.
|
|
156
|
+
|
|
157
|
+
`shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Stripe's optional MCP
|
|
158
|
+
is useful for owner-requested refunds and analytics; honor its approval links. Do not create a
|
|
159
|
+
secret-returning webhook through a conversation tool: the private connect process owns that call.
|
|
122
160
|
|
|
123
161
|
## Payment guarantees and schema
|
|
124
162
|
|
|
@@ -145,7 +183,7 @@ an untagged Dashboard refund revokes items only when the whole order is refunded
|
|
|
145
183
|
Unneeded signed events are acknowledged without recording. A refund or dispute naming an unrecorded payment
|
|
146
184
|
of this database recovers its Checkout Session first, regardless of event age.
|
|
147
185
|
|
|
148
|
-
|
|
186
|
+
**With an API key**, sessions reserve the optional cap until Stripe confirms an outcome. With the default lifetime, a sessionless
|
|
149
187
|
reservation ages out after 24 hours plus a one-minute margin; your `checkoutMinutes` changes that window.
|
|
150
188
|
[Stripe session lifetime](https://docs.stripe.com/api/checkout/sessions/create#create_checkout_session-expires_at)
|
|
151
189
|
and [expiring open sessions](https://docs.stripe.com/api/checkout/sessions/expire) define these provider bounds.
|
|
@@ -153,7 +191,7 @@ Replacing an open checkout expires only reservations at least a minute old; a na
|
|
|
153
191
|
Unresolved orders become eligible for reconciliation after one minute, even with no cap. A buyer's next shop
|
|
154
192
|
or owned-items request schedules up to three reads off the response path, with atomic claims and backoff up
|
|
155
193
|
to an hour; an optional Worker cron invokes the same reconciliation across buyers. No cron is installed for you. To run it every five minutes, add `"triggers": { "crons": ["*/5 * * * *"] }` to the studio’s root `wrangler.jsonc`; deploy preserves its triggers.
|
|
156
|
-
|
|
194
|
+
With an API key, every recorded payment checks Stripe’s refunds and dispute state, including payments recovered by an early refund or dispute event. The thanks page can verify a payment directly with the studio's key. Webhook events require Stripe's signature.
|
|
157
195
|
Test and live books stay separate. Owner release attempts to expire an open session, records who released it
|
|
158
196
|
and when, and never prevents a later verified payment grant.
|
|
159
197
|
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/** Shared setup/runtime contract. Payment Links are public; credentials never enter this value. */
|
|
2
|
+
import { KEY_SHAPE, modeOf } from './stripe.mjs';
|
|
3
|
+
// Current documented Payment Link fields, isolated from the existing keyed transport version.
|
|
4
|
+
export const LINKS_STRIPE_VERSION = '2026-09-30.endive';
|
|
5
|
+
export const hasStripeKey = (env) => KEY_SHAPE.test(String(env?.STRIPE_KEY ?? ''));
|
|
6
|
+
export function linkConfig(env) {
|
|
7
|
+
try { const c = JSON.parse(env?.STRIPE_SHOP_LINKS ?? 'null'); return c?.v === 1 && ['test', 'live'].includes(c.mode) && Array.isArray(c.items) ? c : null; } catch { return null; }
|
|
8
|
+
}
|
|
9
|
+
export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
|
|
10
|
+
export function linkSettings(shop) {
|
|
11
|
+
return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
|
|
12
|
+
cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
|
|
13
|
+
}
|
|
14
|
+
export function fullerReason(shop) {
|
|
15
|
+
if (shop.capPerPlayerMonth !== null) return 'Reserving a spending allowance before payment needs the fuller checkout connection.';
|
|
16
|
+
if (shop.checkoutMinutes !== 1440) return 'Choosing when abandoned checkouts expire needs the fuller checkout connection.';
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export function paymentLinkURL(value) {
|
|
20
|
+
try { const u = new URL(value); return u.protocol === 'https:' && u.hostname === 'buy.stripe.com' && !u.username && !u.password && !u.port; } catch { return false; }
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Only signed snapshot events call this. Store financial fields, never customer/card data.
|
|
24
|
+
* Existing meta is a durable KV table. One row per provider object avoids read/modify/write races.
|
|
25
|
+
* Terminal failed/canceled refunds beat stale succeeded snapshots (including same-second events).
|
|
26
|
+
*/
|
|
27
|
+
export async function rememberMoneyEvent(env, event) {
|
|
28
|
+
const o = event.data.object, mode = event.livemode ? 'live' : 'test';
|
|
29
|
+
const put = async (kind, value, rank) => {
|
|
30
|
+
if (!/^pi_\w+$/.test(value.payment_intent ?? '') || !/^(re|dp)_\w+$/.test(value.id ?? '')) return;
|
|
31
|
+
const key = `shop-money:${mode}:${value.payment_intent}:${kind}:${value.id}`;
|
|
32
|
+
const record = JSON.stringify({ ...value, at: event.created ?? 0, rank });
|
|
33
|
+
await env.DB.prepare(`INSERT INTO meta (key, value) VALUES (?1, ?2) ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
34
|
+
WHERE json_extract(excluded.value, '$.rank') > json_extract(meta.value, '$.rank') OR
|
|
35
|
+
(json_extract(excluded.value, '$.rank') = json_extract(meta.value, '$.rank') AND json_extract(excluded.value, '$.at') >= json_extract(meta.value, '$.at'))`).bind(key, record).run();
|
|
36
|
+
};
|
|
37
|
+
const refund = (r) => put('refund', { id: r.id, payment_intent: r.payment_intent ?? o.payment_intent, amount: r.amount, currency: r.currency ?? o.currency, status: r.status, metadata: r.metadata?.line ? { line: r.metadata.line } : {} }, ['failed', 'canceled'].includes(r.status) ? 3 : r.status === 'succeeded' ? 2 : 1);
|
|
38
|
+
if (event.type.startsWith('refund.')) await refund(o);
|
|
39
|
+
if (event.type === 'charge.refunded') for (const r of o.refunds?.data ?? []) await refund(r);
|
|
40
|
+
if (event.type.startsWith('charge.dispute.')) await put('dispute', { id: o.id, payment_intent: o.payment_intent, status: o.status }, event.type.endsWith('.closed') ? 2 : 1);
|
|
41
|
+
}
|
|
42
|
+
export async function rememberedMoney(env, payment, mode) {
|
|
43
|
+
const prefix = `shop-money:${mode}:${payment}:`;
|
|
44
|
+
const rows = (await env.DB.prepare('SELECT value FROM meta WHERE key >= ?1 AND key < ?2').bind(prefix, prefix + '\uffff').all()).results ?? [];
|
|
45
|
+
return rows.map((r) => JSON.parse(r.value));
|
|
46
|
+
}
|
package/worker/shop-page.mjs
CHANGED
|
@@ -211,7 +211,7 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
211
211
|
if (i.way === 'cap' && i.retryWay) { act.appendChild(el('span', { class: 'why' }, WAY.cap)); i.way = i.retryWay; }
|
|
212
212
|
if (i.way === 'checkout') {
|
|
213
213
|
var amount = null;
|
|
214
|
-
if (i.price === 'choose') {
|
|
214
|
+
if (i.price === 'choose' && !s.keyless) {
|
|
215
215
|
amount = el('input', { type: 'number', 'aria-label': 'How much in ' + s.currency.toUpperCase(), min: String(i.min / s.currencyScale), step: String(s.amountStep / s.currencyScale), value: String(i.min / s.currencyScale) });
|
|
216
216
|
if (i.max !== null) amount.setAttribute('max', String(i.max / s.currencyScale));
|
|
217
217
|
act.appendChild(amount);
|
|
@@ -228,7 +228,8 @@ export const SHOP_JS = String.raw`(function () {
|
|
|
228
228
|
var quantity = el('input', { type: 'number', min: '1', step: '1', value: '1', 'aria-label': 'Quantity' });
|
|
229
229
|
var add = el('button', { type: 'button', class: 'plain' }, 'Add to cart');
|
|
230
230
|
add.onclick = function () { cart.push({ item: i.id, name: i.name, quantity: Number(quantity.value), amount: amount ? units(amount.value, s.currencyScale) : undefined }); drawCart(); };
|
|
231
|
-
act.appendChild(quantity); act.appendChild(add);
|
|
231
|
+
if (!s.keyless) { act.appendChild(quantity); act.appendChild(add); }
|
|
232
|
+
else if (i.price !== 0) act.appendChild(el('p', { class: 'note' }, i.price === 'choose' ? 'Choose your amount on Stripe.' : 'Confirm quantity on Stripe. Buy different items separately.'));
|
|
232
233
|
} else if (i.way === 'owned') {
|
|
233
234
|
act.appendChild(el('span', { class: 'owned' }, 'Yours ✓'));
|
|
234
235
|
} else if (i.way === 'make-an-account') {
|
|
@@ -524,6 +525,7 @@ export const OFFICE_SHOP_SCRIPT = String.raw`(function () {
|
|
|
524
525
|
[['Payments', s.stripe.payments], ['Refunds', s.stripe.refunds], ['Disputes', s.stripe.disputes], ['Payouts', s.stripe.payouts], ['Balance', s.stripe.balance], ['Tax', s.stripe.tax]].concat(s.stripe.managedPayments ? [['Managed Payments', s.stripe.managedPayments]] : []).forEach(function (l) { links.appendChild(link(l[1], l[0])); });
|
|
525
526
|
links.appendChild(link('/_studio/api/shop/orders.csv', 'CSV for your accountant'));
|
|
526
527
|
top.appendChild(links);
|
|
528
|
+
if (s.keyless) top.appendChild(el('p', { class: 'note' }, 'Refund in Stripe or ask your AI connected to Stripe. If a payment is missing here, ask your AI to resend its Stripe webhook.'));
|
|
527
529
|
if (s.totals) top.appendChild(el('p', { class: 'dim' }, 'Last 30 days: ' + s.totals.sales + ' sales, ' + cents(s.totals.paid, s.totals.currency) + ' before tax and fees; ' + s.totals.refunds + ' refunded (' + cents(s.totals.refunded, s.totals.currency) + '); ' + s.totals.disputes + ' disputed now, ' + s.totals.lost + ' lost.'));
|
|
528
530
|
var ord = $('#orders'); ord.textContent = '';
|
|
529
531
|
if (!s.orders.length) ord.appendChild(el('p', { class: 'empty' }, 'No orders yet.'));
|
|
@@ -651,7 +653,7 @@ export async function officeShopPage(cat) {
|
|
|
651
653
|
<div class="game" style="padding:6px 16px 12px"><h3>Orders</h3><div id="orders"></div></div>
|
|
652
654
|
<div class="game" style="padding:6px 16px 12px" id="referrals"></div>
|
|
653
655
|
<div class="toast" id="toast" role="status" hidden></div>
|
|
654
|
-
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing.
|
|
656
|
+
<p class="note">You are the seller. Money goes from players to your own Stripe account; Homie never sees it and takes nothing. Refunds are handled in Stripe or by your connected AI; shops with the fuller checkout connection also offer Refund here. A card dispute never touches the player's account: answer it in Stripe; if you lose it, that one item is taken back. Tax: when enabled, Stripe Tax works out and collects tax where you told Stripe you are registered${cat.shop?.till === 'stripe-managed' ? '; with Managed Payments Stripe is the seller of record and files it for you' : ''}. Payouts, the balance and receipts are Stripe's own pages, linked above. Referrals: you pay referrers yourself (they invoice you); nothing moves through Homie. This is not legal or tax advice.</p>`;
|
|
655
657
|
return new Response(shell(cat, `Shop · ${name}`, body, { script: OFFICE_SHOP_SCRIPT }), {
|
|
656
658
|
headers: { ...PRIVATE, 'content-security-policy': `default-src 'none'; style-src 'unsafe-inline'; img-src data:; script-src '${officeHash}'; connect-src 'self'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'` },
|
|
657
659
|
});
|