@homie-rocks/studio 0.35.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 CHANGED
@@ -17,9 +17,21 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.35.1] - 2026-10-08
21
+
22
+ **Plugin 0.36.0** · [#71](https://github.com/homie-rocks/homie/pull/71)
23
+
24
+ A tip with no minimum connects, and Stripe's own reason is shown when setup cannot finish.
25
+
26
+ ### Fixed
27
+
28
+ - A pay-what-you-want item with no minimum connects.
29
+ - When Stripe refuses something during setup, you are told what Stripe said. Keys are masked, including during renewal, catalog reads and the manual connection.
30
+ - The test Stripe now refuses invalid amounts and incomplete shop objects, so these mistakes fail a test before release.
31
+
20
32
  ## [0.35.0] - 2026-10-08
21
33
 
22
- **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67)
34
+ **Plugin 0.36.0** · [#67](https://github.com/homie-rocks/homie/pull/67) · [release-2026-10-08-studio-0.35.0](https://github.com/homie-rocks/homie/releases/tag/release-2026-10-08-studio-0.35.0)
23
35
 
24
36
  A shop with no Stripe key to find. You approve once in your browser, and your AI sets up the rest in your own Stripe account.
25
37
 
@@ -22,7 +22,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
22
22
  import { readStudio } from './studio.mjs';
23
23
  import { readShop } from './shop.mjs';
24
24
  import { defaultTaxCode, money } from '../worker/shop-rules.mjs';
25
- import { productIdOf } from '../worker/stripe.mjs';
25
+ import { productIdOf, redactStripe } from '../worker/stripe.mjs';
26
26
 
27
27
  const MARK = 'shop-v1';
28
28
 
@@ -58,6 +58,22 @@ export function productsIn(value, out = [], depth = 0) {
58
58
  return out;
59
59
  }
60
60
 
61
+ // Saved MCP responses can wrap a Stripe refusal in text or structured content.
62
+ function catalogError(value, depth = 0) {
63
+ if (depth > 8 || value == null) return null;
64
+ if (typeof value === 'string') {
65
+ try { return catalogError(JSON.parse(value), depth + 1); } catch { return null; }
66
+ }
67
+ if (typeof value !== 'object') return null;
68
+ if (typeof value.error?.message === 'string') return value.error.message;
69
+ if (value.isError === true) return (value.content ?? []).filter((c) => c.type === 'text').map((c) => c.text).join(' ');
70
+ for (const child of Array.isArray(value) ? value : ['data', 'content', 'text', 'result', 'results', 'structuredContent'].map((k) => value[k])) {
71
+ const error = catalogError(child, depth + 1);
72
+ if (error) return error;
73
+ }
74
+ return null;
75
+ }
76
+
61
77
  const taxCodeOf = (p) => (typeof p?.tax_code === 'string' ? p.tax_code : p?.tax_code?.id ?? null);
62
78
 
63
79
  /** The plan: the read first, then (with what Stripe answered) the writes still needed. */
@@ -83,6 +99,8 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
83
99
  ],
84
100
  };
85
101
  }
102
+ const error = catalogError(have);
103
+ if (error) return { ok: false, command: 'shop catalog', why: `Stripe said: “${redactStripe(error).slice(0, 400)}” (GET /v1/products). Read the catalog again after resolving it.` };
86
104
  const products = productsIn(have);
87
105
  const ours = products.filter((p) => p.metadata?.homie_studio === String(slug) || wanted.some((w) => w.id === p.id));
88
106
  const modes = [...new Set(ours.map((p) => (p.livemode ? 'live' : 'test')))];
@@ -149,6 +167,7 @@ export async function catalogPlan(root, { have = null, mode = null, record = tru
149
167
 
150
168
  export function catalogLines(r) {
151
169
  const lines = [];
170
+ if (!r.ok) return [r.why];
152
171
  if (r.step === 'read') {
153
172
  lines.push('The catalog shop.json asks for (one Product an item, the same ids in the sandbox and live):');
154
173
  for (const p of r.products) lines.push(` ${p.id} ${p.name} ${p.price} (tax code ${p.tax_code})`);
@@ -37,8 +37,10 @@ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt
37
37
  let product = products.find((p) => p.id === id);
38
38
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
39
39
  else if (!same(product.metadata, metadata) || product.name !== fields.name || (product.description ?? '') !== fields.description || (product.tax_code?.id ?? product.tax_code) !== fields.tax_code || !product.active) await api('POST', `/v1/products/${id}`, fields);
40
+ // Omit an unset minimum: Stripe applies its settlement-currency floor itself.
41
+ // https://docs.stripe.com/api/prices/create#create_price-custom_unit_amount-minimum
40
42
  const priceFields = { product: id, currency: shop.currency, tax_behavior: 'exclusive', ...(item.price === 'choose'
41
- ? { custom_unit_amount: { enabled: true, minimum: item.min, ...(item.max !== null ? { maximum: item.max } : {}) } }
43
+ ? { custom_unit_amount: { enabled: true, ...(item.min > 0 ? { minimum: item.min } : {}), ...(item.max !== null ? { maximum: item.max } : {}) } }
42
44
  : { unit_amount: item.price }) };
43
45
  const revision = hash(priceFields);
44
46
  let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
package/lib/shop.mjs CHANGED
@@ -205,7 +205,7 @@ async function makeWebhook(env, site, { fetcher }) {
205
205
  const list = await listWebhookEndpoints(env, { fetcher });
206
206
  older = (list?.data ?? []).filter((e) => e?.url === url && e?.metadata?.homie === HOOK_MARK && e?.status !== 'disabled').map((e) => e.id).filter((id) => typeof id === 'string');
207
207
  } catch (error) {
208
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
208
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
209
209
  return { ok: false, page: said(502, `Stripe did not answer the webhook list (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
210
210
  }
211
211
  let made = null;
@@ -215,7 +215,7 @@ async function makeWebhook(env, site, { fetcher }) {
215
215
  description: 'Homie shop (made by homie-studio shop connect)', metadata: { homie: HOOK_MARK },
216
216
  }, { fetcher });
217
217
  } catch (error) {
218
- if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".') };
218
+ if (isPermissionError(error)) return { ok: false, page: said(400, 'This key cannot make webhooks: it needs Webhook Endpoints: Write. In Stripe, open the key (… → Edit key), set Webhook Endpoints to Write and save, then go back and press Save again. Or make the webhook yourself and paste its signing secret under "I made the webhook myself".' + ` Stripe said: ${error.message}`) };
219
219
  return { ok: false, page: said(502, `Stripe did not make the webhook (${error instanceof StripeError ? error.message : 'no answer'}). Nothing was saved. Go back and try again.`) };
220
220
  }
221
221
  const secret = typeof made?.secret === 'string' ? made.secret : '';
@@ -236,7 +236,7 @@ async function probeManaged(env, shop, { fetcher }) {
236
236
  line_items: [{ quantity: 1, price_data: { currency: shop?.currency ?? 'usd', unit_amount: item?.price ?? 500, tax_behavior: 'exclusive', product_data: { name: 'Homie shop check (test, expired at once)', tax_code: defaultTaxCode(item ?? {}) } } }],
237
237
  managed_payments: { enabled: true }, metadata: { homie: 'shop-check' },
238
238
  }, { fetcher, idempotencyKey: `homie-check-${randomBytes(8).toString('hex')}` });
239
- if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch { /* it expires by itself */ } }
239
+ if (typeof s?.id === 'string') { try { await expireCheckoutSession(env, s.id, { fetcher }); } catch (error) { return { ok: false, words: `Stripe took the test checkout but could not expire it (${error instanceof StripeError ? error.message : 'no answer'}). It will expire by itself.` }; } }
240
240
  return { ok: true, words: 'Stripe took a test checkout with Managed Payments: it is on for this account (test mode).' };
241
241
  } catch (error) {
242
242
  return { ok: false, words: `Stripe refused a test checkout with Managed Payments (${error instanceof StripeError ? error.message : 'no answer'}). Finish Managed Payments in Stripe (Settings → Managed Payments: accept the terms, pass the eligibility review), or pick "You are" the seller.` };
@@ -284,14 +284,16 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
284
284
  // One read with the key, so a typo is caught here and not at the first sale. It never leaves this computer
285
285
  // except to Stripe itself.
286
286
  if (verify) {
287
- try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch {
288
- say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.')); return;
287
+ try { await stripeCall(sk, 'GET', '/v1/checkout/sessions', { limit: 1 }, { fetcher }); } catch (error) {
288
+ const reason = error instanceof StripeError ? ` Stripe said: ${error.message}` : '';
289
+ log(`Stripe could not read checkouts.${reason}`);
290
+ say(said(400, 'Stripe did not accept that key for reading checkouts. Check its permissions (Checkout Sessions: Write) and that it is the account you mean, then go back and paste it again.' + reason)); return;
289
291
  }
290
292
  }
291
293
  let webhook = { made: false };
292
294
  if (!hook) {
293
295
  const m = await makeWebhook(sk, site, { fetcher });
294
- if (!m.ok) { say(m.page); return; }
296
+ if (!m.ok) { log(m.page.text); say(m.page); return; }
295
297
  hook = m.secret;
296
298
  webhook = { made: true, id: m.id, older: m.older };
297
299
  }
@@ -303,10 +305,13 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
303
305
  // The new secret is saved: the older endpoints this kit made for the same address stop sending (turned off,
304
306
  // not deleted: the owner can turn one back on in Stripe).
305
307
  let disabled = 0;
308
+ const warnings = [];
306
309
  if (okay && webhook.made) {
307
- for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch { /* left on; Stripe retries and says so */ } }
310
+ for (const id of webhook.older) { try { await updateWebhookEndpoint(sk, id, { disabled: true }, { fetcher }); disabled += 1; } catch (error) { warnings.push(`Stripe could not turn off webhook ${id}: ${error instanceof StripeError ? error.message : 'no answer'}`); } }
308
311
  }
309
312
  const check = okay && !live && chosen === 'stripe-managed' ? await probeManaged(sk, shopNow.shop, { fetcher }) : null;
313
+ if (check && !check.ok) warnings.push(check.words);
314
+ for (const warning of warnings) log(warning);
310
315
  let tillNote = '';
311
316
  if (okay && shopNow.shop && !shopNow.absent && chosen !== shopNow.shop.till) {
312
317
  try {
@@ -317,13 +322,14 @@ export async function shopConnectManual(root, { managed = null, live = false, lo
317
322
  } catch { tillNote = ''; }
318
323
  }
319
324
  const narrow = webhook.made ? ' Recommended, 20 seconds: in Stripe, open this key (… → Edit key) and set Webhook Endpoints back to None. The shop never needs it again.' : '';
320
- say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check ? ` ${check.words}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
325
+ say(said(okay ? 200 : 500, okay ? `Saved to your Worker (${modeOf(key)} mode).${webhook.made ? ' Stripe made the webhook; its secret went straight to your Worker.' : ''}${check?.ok ? ` ${check.words}` : ''}${warnings.length ? ` ${warnings.join(' ')}` : ''}${narrow} You can close this page.` : 'Wrangler could not save it (is this computer signed in to Cloudflare? npx wrangler login). Nothing was kept.'));
321
326
  server.close();
322
327
  done(okay
323
328
  ? {
324
329
  ok: true, command: 'shop connect', saved: true, mode: modeOf(key), till: chosen,
325
330
  webhook: webhook.made ? { made: true, id: webhook.id, url: hookUrlOf(site), turnedOff: disabled } : { made: false, url: hookUrlOf(site) },
326
331
  ...(check ? { managedPayments: check } : {}),
332
+ ...(warnings.length ? { warnings } : {}),
327
333
  message: `Saved as the Worker secrets STRIPE_KEY and STRIPE_WEBHOOK_SECRET (never shown), ${modeOf(key)} mode, seller: ${chosen === 'stripe-managed' ? 'Stripe (Managed Payments)' : 'the studio'}.${webhook.made ? ` Stripe made the webhook ${webhook.id} for ${hookUrlOf(site)}${disabled ? ` and ${disabled} older one(s) were turned off` : ''}.` : ''}${tillNote}${narrow} homie-studio shop says whether anything else is missing.`,
328
334
  }
329
335
  : { ok: false, command: 'shop connect', why: 'wrangler secret put failed (sign in with npx wrangler login, then run this again)' });
@@ -11,7 +11,7 @@ import { readStudio, siteUrl } from './studio.mjs';
11
11
  import { hookUrlOf, readShop, shopConnectManual } from './shop.mjs';
12
12
  import { syncPaymentLinks } from './shop-links.mjs';
13
13
  import { LINKS_STRIPE_VERSION, fullerReason } from '../worker/shop-links.mjs';
14
- import { formEncode } from '../worker/stripe.mjs';
14
+ import { formEncode, redactStripe } from '../worker/stripe.mjs';
15
15
 
16
16
  const hash = (s) => createHash('sha256').update(s).digest('hex');
17
17
  const failure = (needs, why, extra = {}) => ({ ok: false, command: 'shop connect', needs, why, ...extra });
@@ -28,6 +28,14 @@ export function stripeConnectionInfo(root, { storage = receiptHome(), now = Date
28
28
  }
29
29
 
30
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
+
31
39
  // This output stays private, including CLI config, completion, errors and API responses.
32
40
  export function stripeProcess(args, { cwd, env = process.env } = {}) {
33
41
  const childEnv = { ...env, CI: '1', NO_COLOR: '1' };
@@ -94,15 +102,15 @@ export async function shopConnect(root, options = {}) {
94
102
  const login = json(start.stdout);
95
103
  const complete = completionArgs(login?.next_step);
96
104
  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.');
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'));
98
106
  }
99
107
  log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
100
108
  open(login.browser_url);
101
109
  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.');
110
+ if (finished.code !== 0) return failure('stripe-login', 'Stripe approval did not finish. Run shop connect again when ready.' + stripeReason(finished, 'login completion'));
103
111
 
104
112
  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.');
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'));
106
114
  }
107
115
  const site = siteUrl(root);
108
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 });
@@ -114,7 +122,9 @@ export async function shopConnect(root, options = {}) {
114
122
  for (const [k, v] of formEncode(params)) args.push('-d', `${k}=${v}`);
115
123
  const r = await cli(args);
116
124
  const body = json(r.stdout);
117
- if (r.code !== 0 || !body || body.error) throw new Error('Stripe API operation failed');
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
+ }
118
128
  return body;
119
129
  };
120
130
  const reason = fullerReason(checked.shop);
@@ -129,7 +139,8 @@ export async function shopConnect(root, options = {}) {
129
139
  renameSync(`${receiptFile}.tmp`, receiptFile);
130
140
  } });
131
141
  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.');
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);
134
145
  } finally { lock.release(); }
135
146
  }
@@ -1906,6 +1906,46 @@
1906
1906
  }
1907
1907
  }
1908
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",
1934
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
1935
+ "## Rules": "2e6b29afb85dcf7e",
1936
+ "## Telling Homie": "80b46fdd9e578db8",
1937
+ "## Beta": "9d02638207a26e73"
1938
+ }
1939
+ },
1940
+ "named": {
1941
+ "Homie Arcade": {
1942
+ "sections": {
1943
+ "AGENTS.md": {
1944
+ "## Commands (all through the pinned CLI in node_modules)": "7cad8fd10f62fef1"
1945
+ }
1946
+ }
1947
+ }
1948
+ }
1909
1949
  }
1910
1950
  }
1911
1951
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.35.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/worker/stripe.mjs CHANGED
@@ -55,13 +55,13 @@ export function formEncode(params, prefix = '', out = new URLSearchParams()) {
55
55
  export function redactStripe(value, env = {}) {
56
56
  let text = String(value ?? '');
57
57
  for (const secret of [env.STRIPE_KEY, env.STRIPE_WEBHOOK_SECRET]) if (secret) text = text.replaceAll(String(secret), '[redacted]');
58
- return text.replace(/(?:[rs]k_(?:test|live)_[A-Za-z0-9]+|whsec_[A-Za-z0-9+/=_-]+|[A-Za-z0-9_]+_secret_[A-Za-z0-9_-]+)/g, '[redacted]');
58
+ return text.replace(/(?:(?:sk|rk|pk|whsec|oak)_[A-Za-z0-9+/=_-]+|[A-Za-z0-9_]+_secret_[A-Za-z0-9_-]+)/g, '[redacted]');
59
59
  }
60
60
 
61
61
  export class StripeError extends Error {
62
62
  constructor(status, body) {
63
63
  const e = body?.error ?? {};
64
- super(e.message ? redactStripe(e.message).slice(0, 300) : `Stripe answered ${status}`);
64
+ super(e.message ? redactStripe(e.message).slice(0, 400) : `Stripe answered ${status}`);
65
65
  this.status = status;
66
66
  this.code = redactStripe(e.code ?? e.type ?? 'stripe');
67
67
  this.type = e.type ? redactStripe(e.type) : null;
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.35.0';
2
+ export const STUDIO_VERSION_TAG = '0.35.1';