@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.
@@ -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)": "57c04f6526e16890",
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.34.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
- A cart is one payment with several lines and quantities. Your office can refund a whole order or a line.
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` provides a local page for the key and webhook signing secret; both are part of payment setup, credentials stay in Worker secrets.
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` opens a local page for the studio's Stripe key and webhook secret; `--live` selects live mode.
108
- Both restricted (`rk_`) and full secret (`sk_`) keys are accepted. A restricted key is useful: Checkout Sessions Write, Charges Write, PaymentIntents Read, Disputes Read;
109
- Webhook Endpoints Write lets the connect page create the webhook. The key and webhook signing secret are both
110
- part of working payment setup; without either, readiness names the missing step and opens no checkout.
111
- [Stripe fulfillment](https://docs.stripe.com/checkout/fulfillment) uses webhooks as well as the redirect.
112
- Credentials stay in Worker secrets. A preview normally has no database and cannot sell. A preview that you
113
- configure with a database, key and signing secret sells into that database.
114
- Stripe's agent tools can populate Products with `shop catalog` and read sales; secret-returning endpoint
115
- creation belongs on the connect page. Stripe approval links are handled by the owner.
116
-
117
- `shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Office refunds are owner actions;
118
- `shop refund <order>` asks the owner to confirm. Stripe controls pending or held refunds.
119
- The office links Stripe's disputes and provides CSV exports. A dispute does not lock a player's account;
120
- a lost dispute revokes the order's entitlements. Referral statements record what the studio owes;
121
- the studio pays referrers directly and marks payment in its office.
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
- Sessions reserve the optional cap until Stripe confirms an outcome. With the default lifetime, a sessionless
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
- 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.
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
+ }
@@ -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. Refund is one tap here (your AI can only ask, and you confirm). 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>`;
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
  });