@homie-rocks/studio 0.37.1 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/bin/homie-studio.mjs +7 -6
  3. package/lib/cloudflare.mjs +28 -3
  4. package/lib/dev.mjs +1 -1
  5. package/lib/parts-build.mjs +23 -4
  6. package/lib/parts-cli.mjs +54 -3
  7. package/lib/parts-purchase.mjs +174 -0
  8. package/lib/parts-store.mjs +50 -19
  9. package/lib/parts-tools.mjs +15 -12
  10. package/lib/parts-upload.mjs +43 -0
  11. package/lib/parts.mjs +49 -5
  12. package/lib/perf.mjs +17 -10
  13. package/lib/port-check.mjs +77 -18
  14. package/lib/purchase-links.mjs +42 -0
  15. package/lib/purchase-wallet.mjs +114 -0
  16. package/lib/rules-check-worker.mjs +15 -2
  17. package/lib/scaffold.mjs +25 -5
  18. package/lib/shoot.mjs +12 -6
  19. package/lib/shop-links.mjs +15 -6
  20. package/lib/shop.mjs +91 -21
  21. package/lib/stripe-connect.mjs +2 -2
  22. package/lib/template-history.json +85 -0
  23. package/netplay/NETPLAY.md +20 -6
  24. package/netplay/netplay.ts +3 -1
  25. package/package.json +12 -2
  26. package/parts/COMMERCIAL-LICENCE.md +21 -0
  27. package/parts/PARTS.md +238 -10
  28. package/port/probe.ts +2 -1
  29. package/rules/core.ts +3 -1
  30. package/rules/host.ts +3 -1
  31. package/rules/pack.ts +30 -3
  32. package/rules/view.ts +232 -40
  33. package/starters/coin-dash/lab.json +17 -0
  34. package/starters/coin-dash/src/rules.ts +3 -3
  35. package/starters/gem-rush/game.json +19 -4
  36. package/starters/gem-rush/map/main.json +50 -0
  37. package/starters/gem-rush/src/move.ts +23 -0
  38. package/starters/gem-rush/src/rules.ts +118 -0
  39. package/starters/gem-rush/src/view.ts +429 -0
  40. package/starters/gem-rush/tunables.json +98 -11
  41. package/worker/extensions.mjs +13 -0
  42. package/worker/index.mjs +3 -2
  43. package/worker/machine-payments.mjs +72 -0
  44. package/worker/office.mjs +4 -1
  45. package/worker/parts-protected.mjs +111 -0
  46. package/worker/parts-purchase-catalogue.mjs +34 -0
  47. package/worker/parts-resource.mjs +32 -0
  48. package/worker/parts-sale.mjs +59 -0
  49. package/worker/parts.mjs +24 -11
  50. package/worker/payment-capabilities.mjs +70 -0
  51. package/worker/payment-dry-exchange.mjs +30 -0
  52. package/worker/payment-manual-refund.mjs +40 -0
  53. package/worker/payment-policy.mjs +40 -0
  54. package/worker/payment-recovery.mjs +163 -0
  55. package/worker/payment-test-exchange.mjs +19 -0
  56. package/worker/purchase-core.mjs +140 -0
  57. package/worker/purchase-crypto.mjs +17 -0
  58. package/worker/purchase-delivery.mjs +108 -0
  59. package/worker/purchase-discovery.mjs +26 -0
  60. package/worker/purchase-hosted-readiness.mjs +16 -0
  61. package/worker/purchase-keys.mjs +20 -0
  62. package/worker/purchase-links.mjs +9 -0
  63. package/worker/purchase-machine.mjs +279 -0
  64. package/worker/purchase-mcp.mjs +42 -0
  65. package/worker/purchase-office-routes.mjs +17 -0
  66. package/worker/purchase-office-script.mjs +38 -0
  67. package/worker/purchase-office.mjs +35 -0
  68. package/worker/purchase-owner.mjs +243 -0
  69. package/worker/purchase-pricing.mjs +8 -0
  70. package/worker/purchase-problem.mjs +4 -0
  71. package/worker/purchase-reconciliation.mjs +582 -0
  72. package/worker/purchase-response.mjs +82 -0
  73. package/worker/purchase-routes.mjs +611 -0
  74. package/worker/purchase-schema.mjs +50 -0
  75. package/worker/purchase-store.mjs +148 -0
  76. package/worker/purchases.mjs +6 -0
  77. package/worker/resource-kinds.mjs +20 -0
  78. package/worker/selling/index.mjs +8 -0
  79. package/worker/selling/runtime.mjs +42 -0
  80. package/worker/settlement-journal.mjs +138 -0
  81. package/worker/shop-page.mjs +7 -4
  82. package/worker/shop-refunds.mjs +22 -0
  83. package/worker/shop-rules.mjs +1 -1
  84. package/worker/shop.mjs +18 -18
  85. package/worker/stripe.mjs +36 -3
  86. package/worker/version.mjs +1 -1
  87. package/starters/gem-rush/src/main.ts +0 -1087
@@ -60,6 +60,25 @@ function yawDeg(a, b) {
60
60
  const d = a[5] * b[5] + a[6] * b[6]; const la = Math.hypot(a[5], a[6]) || 1; const lb = Math.hypot(b[5], b[6]) || 1;
61
61
  return (Math.acos(Math.max(-1, Math.min(1, d / (la * lb)))) * 180) / Math.PI;
62
62
  }
63
+ /** A server room keeps its authority and advances; a browser room must promote a host. */
64
+ export function roomContinued(before, after, serverHosted) {
65
+ return serverHosted
66
+ ? after?.hosted === 'server' && after?.role === 'replica' && Number.isFinite(before?.tick) && after?.tick > before.tick && after?.frames > before.frames
67
+ : after?.role === 'host';
68
+ }
69
+
70
+ /** Compare two observations on the same browser clock, including time spent taking screenshots. */
71
+ export function roundClockContinued(before, after) {
72
+ return !before?.round || !after?.round || before.round.n !== after.round.n
73
+ || Math.abs((before.round.leftMs - after.round.leftMs) - (after.observedAt - before.observedAt)) < 6000;
74
+ }
75
+
76
+ /** A wall can leave too little runway; retry the same hold after moving away, never excuse steering faults. */
77
+ export function needsRunway(res) {
78
+ return !res.ok && (res.why === 'barely moved' || res.why?.startsWith('blocked at once'))
79
+ && res.yawChangeDeg < 10 && (res.dirErrDeg === null || res.dirErrDeg < 15);
80
+ }
81
+
63
82
  const finite = (r) => r && Number.isFinite(r[1]) && Number.isFinite(r[2]);
64
83
  /** A frame the player steers (the probe's `busy` says when a knockback, a stun or a respawn has the body). */
65
84
  const steerable = (r) => finite(r) && !r[8];
@@ -134,6 +153,9 @@ export function judgePresses(rows, presses, size, view) {
134
153
  const out = presses.map((p) => {
135
154
  const seg = rows.filter((r) => r[0] >= p.a && r[0] <= p.b + 60 && steerable(r));
136
155
  if (seg.length < 4) return rows.some((r) => r[0] >= p.a && r[0] <= p.b && finite(r) && r[8]) ? { dir: p.dir, ok: false, blocked: true, busy: true } : { dir: p.dir, ok: false, why: 'no samples' };
156
+ // The first post-input frame can already show the response. Keep its preceding drawn pose.
157
+ const before = rows.filter((r) => r[0] < p.a).at(-1);
158
+ if (before && steerable(before)) seg.unshift(before);
137
159
  const u = DIRV[p.dir]; const r0 = seg[0];
138
160
  let matchAt = null, after = 0, afterOk = 0, maxYaw = 0, peakAlong = 0, contact = false;
139
161
  for (let i = 1; i < seg.length; i++) {
@@ -258,7 +280,7 @@ async function webkit(pw) {
258
280
  const frameOf = (h) => h.page.frames().find((f) => f.url().includes('/__game/')) ?? null;
259
281
  async function inFrame(h, src, v = null) { const f = frameOf(h); return f ? T(f.evaluate(src), 10_000, v) : v; }
260
282
  const shell = (h) => T(h.page.evaluate(() => { const s = window.__shell; return s ? { room: s.room, seat: s.seat, role: s.stats?.role ?? null, results: s.results ?? [], facts: s.facts ? { counts: s.facts.counts } : null } : null; }), 8000, null);
261
- const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
283
+ const info = (h) => inFrame(h, '(() => { const p = window.__homiePort; if (!p) return null; const i = p.info(); i.observedAt = performance.now(); const n = window.__homieNet; i.net = n ? { snapHzIn: n.stats().snapHzIn, snapHzOut: n.stats().snapHzOut, rtt: n.stats().rtt, promotions: n.stats().promotions } : null; i.hosted = n?.probe?.hosted?.() ?? null; i.tick = n?.probe?.tick?.() ?? null; i.v = p.view; i.size = p.size; i.keys = p.keys; i.thumb = p.thumb; return i; })()');
262
284
  const rowsSince = (h, t) => inFrame(h, `(window.__homiePort ? window.__homiePort.rows(${Number(t) || 0}) : [])`, []);
263
285
  const frameNow = (h) => inFrame(h, '(window.__homiePort ? window.__homiePort.now() : performance.now())', 0);
264
286
 
@@ -285,13 +307,14 @@ async function focusGame(h) {
285
307
 
286
308
  /* ------------------------------------------------------------------ input */
287
309
 
288
- async function holdKey(h, code, ms) { await h.page.keyboard.down(code); await sleep(ms); await h.page.keyboard.up(code); }
310
+ async function holdKey(h, code, ms, ready) { await h.page.keyboard.down(code); if (ready) await ready(); await sleep(ms); await h.page.keyboard.up(code); }
289
311
 
290
312
  function thumbAt(h, i) { const t = i?.thumb ?? [0.24, 0.74]; return [Math.round(h.vp.width * t[0]), Math.round(h.vp.height * t[1])]; }
291
- async function touchDrag(h, from, dir, reach, holdMs) {
313
+ async function touchDrag(h, from, dir, reach, holdMs, ready) {
292
314
  const [x0, y0] = from; const u = DIRV[dir];
293
315
  await h.touch('touchStart', [{ id: 1, x: x0, y: y0 }]);
294
316
  for (let k = 1; k <= 8; k++) { await h.touch('touchMove', [{ id: 1, x: Math.round(x0 + (u[0] * reach * k) / 8), y: Math.round(y0 + (u[1] * reach * k) / 8) }]); await sleep(12); }
317
+ if (ready) await ready();
295
318
  const end = Date.now() + holdMs;
296
319
  let w = 0;
297
320
  while (Date.now() < end) {
@@ -302,6 +325,31 @@ async function touchDrag(h, from, dir, reach, holdMs) {
302
325
  }
303
326
  await h.touch('touchEnd', h.touchAll ? [] : [{ id: 1, x: Math.round(x0 + u[0] * reach), y: Math.round(y0 + u[1] * reach) }]);
304
327
  }
328
+ /** Timestamp real input delivery, then keep it held until four frames can judge its direction.
329
+ * The response limit stays 600 ms; slow automation delivery and the thumb's ramp are not game latency. */
330
+ export async function measuredPress(h, how, code, from, dir) {
331
+ await inFrame(h, `(() => {
332
+ if (!window.__homieCheckInput) {
333
+ const state = window.__homieCheckInput = {};
334
+ addEventListener('keydown', e => { if (state.code === e.code && state.key === null) state.key = performance.now(); }, true);
335
+ addEventListener('touchmove', () => { state.touch = performance.now(); }, { capture: true, passive: true });
336
+ }
337
+ Object.assign(window.__homieCheckInput, { code: ${JSON.stringify(code)}, key: null, touch: null });
338
+ })()`);
339
+ let a = null, b = null;
340
+ const ready = async () => {
341
+ a = await inFrame(h, `window.__homieCheckInput.${how === 'keys' ? 'key' : 'touch'}`);
342
+ if (!Number.isFinite(a)) throw new Error('the game frame did not receive the measured input');
343
+ await sleep(420);
344
+ const until = Date.now() + 3000;
345
+ while (Date.now() < until && (await rowsSince(h, a)).length < 4) await sleep(50);
346
+ b = await frameNow(h);
347
+ };
348
+ if (how === 'keys') await holdKey(h, code, 0, ready);
349
+ else await touchDrag(h, from, dir, 70, 0, ready);
350
+ return { dir, a, b };
351
+ }
352
+
305
353
  async function swipe(h, dir) {
306
354
  const x0 = Math.round(h.vp.width * 0.5); const y0 = Math.round(h.vp.height * 0.55); const u = DIRV[dir]; const reach = Math.round(Math.min(h.vp.width, h.vp.height) * 0.3);
307
355
  await h.touch('touchStart', [{ id: 2, x: x0, y: y0 }]);
@@ -365,11 +413,22 @@ async function ownerTests(h, how, log) {
365
413
  const holds = view === 'side' ? ['right', 'left'] : ['down', 'left'];
366
414
  out.holds = [];
367
415
  for (const dir of holds) {
368
- const a = await frameNow(h);
369
- if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
370
- const b = await frameNow(h);
371
- await sleep(250);
372
- const res = judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
416
+ const hold = async () => {
417
+ const a = await frameNow(h);
418
+ if (how === 'keys') await holdKey(h, keys[dir], 5000); else await touchDrag(h, thumbAt(h, i), dir, 70, 5000);
419
+ const b = await frameNow(h);
420
+ await sleep(250);
421
+ return judgeHold(await rowsSince(h, a - 50), dir, a, b, size, view);
422
+ };
423
+ let res = await hold();
424
+ if (needsRunway(res)) {
425
+ const first = res;
426
+ const away = { up: 'down', down: 'up', left: 'right', right: 'left' }[dir];
427
+ log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${first.why}; move ${away} and retry the same hold`);
428
+ if (how === 'keys') await holdKey(h, keys[away], 1100); else await touchDrag(h, thumbAt(h, i), away, 70, 1100);
429
+ await sleep(400);
430
+ res = { ...await hold(), runwayRetry: first };
431
+ }
373
432
  out.holds.push(res);
374
433
  log(` ${h.engine}-${h.kind} ${how} hold ${dir}: ${res.ok ? 'ok' : `FAIL (${res.why})`} travel ${res.travelBodies} bodies, yaw ${res.yawChangeDeg}°`);
375
434
  await sleep(400);
@@ -383,12 +442,9 @@ async function ownerTests(h, how, log) {
383
442
  const presses = [];
384
443
  const a0 = await frameNow(h);
385
444
  const until = Date.now() + 10_000;
386
- for (let k = 0; Date.now() < until; k++) {
445
+ for (let k = 0; k < alt.length || Date.now() < until; k++) {
387
446
  const dir = alt[k % alt.length];
388
- const a = await frameNow(h);
389
- if (how === 'keys') await holdKey(h, keys[dir], 420); else await touchDrag(h, thumbAt(h, i), dir, 70, 380);
390
- const b = await frameNow(h);
391
- presses.push({ dir, a, b });
447
+ presses.push(await measuredPress(h, how, keys[dir], thumbAt(h, i), dir));
392
448
  await sleep(170);
393
449
  }
394
450
  await sleep(200);
@@ -595,10 +651,13 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
595
651
  if (!oa.ok || !ob.ok) { row('host-kill', false, { why: 'a browser was never seated' }); row('late-join', null, { why: 'skipped: no room' }); collect(A); collect(B); await close(A); await close(B); }
596
652
  else {
597
653
  await sleep(3000);
598
- const ia = await info(A); const ib = await info(B);
599
- if (ia?.role !== 'host') row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
654
+ const ia = await info(A); let ib = await info(B);
655
+ const serverHosted = ia?.hosted === 'server' && ib?.hosted === 'server';
656
+ if (ia?.role !== 'host' && !serverHosted) row('host-kill', null, { why: `the first browser was not the host (it was ${ia?.role}); skipped`, ia: ia?.role, ib: ib?.role });
600
657
  else {
601
658
  await shot(B, 'life-before');
659
+ // Screenshot encoding may span a round on a software renderer. Observe the room we actually leave.
660
+ ib = await info(B);
602
661
  const pids = A.pid ? [A.pid, ...descendants(A.pid)] : [];
603
662
  const killAt = Date.now();
604
663
  for (const p of pids) { try { process.kill(p, 'SIGKILL'); } catch { /* gone */ } }
@@ -606,14 +665,14 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
606
665
  collect(A);
607
666
  void A.close();
608
667
  let promoted = null; let ibAfter = null;
609
- while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (ibAfter?.role === 'host') { promoted = Date.now() - killAt; break; } await sleep(100); }
668
+ while (Date.now() - killAt < 15_000) { ibAfter = await info(B); if (roomContinued(ib, ibAfter, serverHosted)) { promoted = Date.now() - killAt; break; } await sleep(100); }
610
669
  await sleep(1500);
611
670
  const ib2 = await info(B);
612
671
  const sameRound = ib?.round && ib2?.round && (ib2.round.n === ib.round.n || (ib.round.phase === 'over' || ib.round.leftMs < 3000));
613
- const clockOk = ib?.round && ib2?.round && ib2.round.n === ib.round.n ? Math.abs((ib.round.leftMs - ib2.round.leftMs) - (Date.now() - killAt + 0)) < 6000 : true;
672
+ const clockOk = roundClockContinued(ib, ib2);
614
673
  const running = (ib2?.frames ?? 0) > (ibAfter?.frames ?? 0) && (ib2?.net?.snapHzOut ?? 0) >= 0;
615
674
  await shot(B, 'life-after-kill');
616
- row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { promotedMs: promoted, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? 'nobody took over as host within 15 s' : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
675
+ row('host-kill', Boolean(promoted !== null && promoted <= 5000 && sameRound && clockOk && running), { hosted: serverHosted ? 'server' : 'browser', promotedMs: serverHosted ? null : promoted, continuedMs: serverHosted ? promoted : null, roundBefore: ib?.round ?? null, roundAfter: ib2?.round ?? null, why: promoted === null ? (serverHosted ? 'the server room stopped after a browser left' : 'nobody took over as host within 15 s') : promoted > 5000 ? `the phone took over after ${promoted} ms (over 5 s)` : !sameRound ? 'the round did not continue (a new round started)' : !clockOk ? 'the round clock jumped at the takeover' : undefined });
617
676
  // Late joiner into the same, running room.
618
677
  const C = await launch('desk');
619
678
  const joinAt = Date.now();
@@ -0,0 +1,42 @@
1
+ /** Uses the shop's authorized Stripe API transport; no API credential goes to the Worker. */
2
+ import { createHash } from 'node:crypto';
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { sharedPartsOf, readPart, packedDir } from './parts.mjs';
6
+ import { canonicalJson } from '../worker/referrals.mjs';
7
+ import { paymentLinkURL } from '../worker/shop-links.mjs';
8
+ const hash = (v) => createHash('sha256').update(canonicalJson(v)).digest('hex');
9
+ export async function syncPurchaseLinks({ root, api, slug, site, mode, list, till }) {
10
+ const file = join(root, 'parts', 'offers.json');
11
+ const settings = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : {};
12
+ const offers = sharedPartsOf(root).flatMap(({id, versions, part}) => versions.flatMap((version) => {
13
+ const p = readPart(packedDir(root,id,version)), config = settings[id], selected = config?.releases?.[version];
14
+ return p.sale && config?.active !== false && selected?.active !== false && (version === versions.at(-1) || selected?.keepOnSale === true) ? [{kind:'part',resource:id,version,name:part.name,sale:{...p.sale,...config?.price}}] : [];
15
+ }));
16
+ const configured = offers.flatMap(o => {
17
+ const quantities = settings[o.resource]?.quantities ?? [1];
18
+ if (!Array.isArray(quantities) || !quantities.length || quantities.some(q=>!Number.isSafeInteger(q) || q<1 || !Number.isSafeInteger(q*o.sale.amount))) throw new Error(`Invalid Payment Link quantities for ${o.resource}`);
19
+ return [...new Set(quantities)].map(quantity=>({...o,quantity}));
20
+ });
21
+ const links = (await list('/v1/payment_links')).filter((l) => l.metadata?.homie === 'purchase-links-v1' && l.metadata?.studio === slug && l.metadata?.origin === site);
22
+ const config = {v:1,mode,offers:[]}, keep = new Set();
23
+ for (const o of configured) {
24
+ const revision = hash({o,till});
25
+ let link = links.find((l)=>l.metadata?.revision === revision);
26
+ if (!link) {
27
+ const metadata = {homie:'purchase-links-v1',studio:slug,origin:site,revision};
28
+ const product = (await list('/v1/products')).find(p=>p.metadata?.homie === 'purchase-links-v1' && p.metadata?.studio === slug && p.metadata?.origin === site && p.metadata?.revision === revision) ?? await api('POST','/v1/products',{name:`${o.name} ${o.version}`, ...(o.sale.taxCode ? {tax_code:o.sale.taxCode}:{}),metadata},`purchase-product-${revision}`);
29
+ const price = (await list('/v1/prices')).find(p=>p.product === product.id && p.metadata?.revision === revision) ?? await api('POST','/v1/prices',{product:product.id,currency:o.sale.currency,unit_amount:o.sale.amount,...(o.sale.taxBehavior ? {tax_behavior:o.sale.taxBehavior}:{}),...(o.sale.billing !== 'one-time' ? {recurring:{interval:o.sale.billing}}:{}),metadata},`purchase-price-${revision}`);
30
+ link = await api('POST','/v1/payment_links',{line_items:[{price:price.id,quantity:o.quantity}],metadata,
31
+ ...(o.sale.billing === 'one-time' ? {payment_intent_data:{metadata}}:{subscription_data:{metadata}}),
32
+ ...(till === 'stripe-managed' ? {managed_payments:{enabled:true}} : {automatic_tax:{enabled:o.sale.automaticTax === true},...(o.sale.taxIdCollection?{tax_id_collection:{enabled:true}}:{}),...(o.sale.billing === 'one-time' && o.sale.invoiceCreation?{invoice_creation:{enabled:true}}:{}),...(o.sale.adaptivePricing === undefined?{}:{adaptive_pricing:{enabled:o.sale.adaptivePricing}})}),
33
+ ...(o.sale.billing === 'one-time' && o.sale.customerCreation?{customer_creation:'always'}:{}),allow_promotion_codes:o.sale.promotionCodes === true,
34
+ after_completion:{type:'redirect',redirect:{url:`${site}/purchases/thanks`}}},`purchase-link-${revision}`);
35
+ }
36
+ if (!paymentLinkURL(link.url)) throw new Error('Stripe returned an invalid Payment Link');
37
+ if (!link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:true});
38
+ keep.add(link.id);
39
+ config.offers.push({kind:o.kind,resource:o.resource,version:o.version,quantity:o.quantity,offer:canonicalJson(o.sale),id:link.id,url:link.url,revision});
40
+ }
41
+ return { config, retire:async()=>{ for (const l of links) if(l.active && !keep.has(l.id)) await api('POST',`/v1/payment_links/${l.id}`,{active:false}); } };
42
+ }
@@ -0,0 +1,114 @@
1
+ /** Payment authority and limits stay in the person's existing wallet client. */
2
+ import { execFile } from "node:child_process";
3
+ import { promisify } from "node:util";
4
+ const execute = promisify(execFile);
5
+ export async function payWithWallet(
6
+ url,
7
+ body,
8
+ { client = process.env.HOMIE_PARTS_WALLET, pending, run = execute } = {},
9
+ ) {
10
+ if (!client) return null;
11
+ if (!["link-cli", "purl", "tempo"].includes(client))
12
+ return { data: { detail: "Choose link-cli, purl or tempo as the wallet; hosted Checkout is available." } };
13
+ let args;
14
+ if (client === "link-cli") {
15
+ args = [
16
+ "mpp",
17
+ "pay",
18
+ url,
19
+ "-X",
20
+ "POST",
21
+ "-d",
22
+ JSON.stringify(body),
23
+ "--format",
24
+ "json",
25
+ ];
26
+ if (pending?.id)
27
+ args.push(
28
+ "--spend-request-id",
29
+ pending.id,
30
+ "--approved-challenge",
31
+ pending.challenge,
32
+ );
33
+ else
34
+ args.push(
35
+ "--amount",
36
+ String(body.amount),
37
+ "--context",
38
+ `Purchase the digital resource ${body.resource} version ${body.version} from ${new URL(url).host} under the exact price and licence terms approved in this conversation. The selling studio delivers the files and signed purchase proof.`,
39
+ );
40
+ } else {
41
+ const atomic = BigInt(body.amount) * 10000n;
42
+ const purlLimit = process.env.PURL_MAX_AMOUNT;
43
+ if (purlLimit && !/^\d+$/.test(purlLimit))
44
+ return { data: { detail: "Invalid wallet maximum amount; use hosted Checkout or correct the wallet setting." } };
45
+ const tempoLimit = process.env.TEMPO_MAX_SPEND;
46
+ if (
47
+ tempoLimit &&
48
+ (!Number.isFinite(Number(tempoLimit)) || Number(tempoLimit) < 0)
49
+ )
50
+ return { data: { detail: "Invalid wallet maximum spend; use hosted Checkout or correct the wallet setting." } };
51
+ // Use the wallet's native per-request ceiling in its documented units.
52
+ const ceiling =
53
+ client === "tempo"
54
+ ? [
55
+ "--max-spend",
56
+ String(
57
+ Math.min(
58
+ body.amount / 100,
59
+ tempoLimit ? Number(tempoLimit) : Infinity,
60
+ ),
61
+ ),
62
+ ]
63
+ : [
64
+ "--max-amount",
65
+ String(
66
+ purlLimit && BigInt(purlLimit) < atomic
67
+ ? BigInt(purlLimit)
68
+ : atomic,
69
+ ),
70
+ "--network",
71
+ "base,base-sepolia",
72
+ "--output-format",
73
+ "json",
74
+ ];
75
+ args = [
76
+ ...(client === "tempo" ? ["request"] : []),
77
+ ...ceiling,
78
+ "-X",
79
+ "POST",
80
+ "--json",
81
+ JSON.stringify(body),
82
+ url,
83
+ ];
84
+ }
85
+ let stdout;
86
+ try { ({ stdout } = await run(client, args, {
87
+ encoding: "utf8",
88
+ maxBuffer: 256 * 1024 * 1024,
89
+ timeout: 60000,
90
+ })); } catch (error) {
91
+ if (error.code === 'ENOENT') return { data: { detail: `The ${client} wallet is not installed; use hosted Checkout.` } };
92
+ // Wallets often return a structured refusal with a nonzero exit status.
93
+ // A process timeout or broken output is ambiguous: never start a second payment.
94
+ if (error.stdout?.trim()) stdout = error.stdout;
95
+ else if (/declined|insufficient funds|spend limit|not authenticated|not logged in|unsupported (network|method)|user rejected|user denied/i.test(error.stderr ?? ''))
96
+ return { data: { detail: String(error.stderr).trim(), paymentStatus: 'not_paid' } };
97
+ else return { uncertain: true, data: { detail: 'The wallet stopped without a payment result. Retry this purchase to reconcile it; do not pay again.' } };
98
+ }
99
+ let result;
100
+ try { result = JSON.parse(stdout); }
101
+ catch { return { uncertain: true, data: { detail: 'The wallet returned an unreadable payment result. Retry this purchase to reconcile it; do not pay again.' } }; }
102
+ if (result._next?.pay_argv) {
103
+ const next = result._next.pay_argv.args;
104
+ return {
105
+ pending: {
106
+ id: result.id,
107
+ challenge: next[next.indexOf("--approved-challenge") + 1],
108
+ },
109
+ approval: result.approval_url,
110
+ };
111
+ }
112
+ const value = result.body ?? result;
113
+ return { data: typeof value === "string" ? JSON.parse(value) : value };
114
+ }
@@ -183,12 +183,14 @@ function play(c, companions, allow) {
183
183
  const free = (seat) => { if (!seated.has(seat)) return; seated.delete(seat); did.push(`seat ${seat} left`); frames.push({ t: 'free', seat }); };
184
184
  const bots = (mode) => { policy = { ...(policy ?? {}), bots: mode }; frames.push({ t: 'policy', policy }); };
185
185
 
186
- let A = open({ observe, noted, send });
186
+ const movesToCheck = [];
187
+ const moved = (...args) => movesToCheck.push(args);
188
+ let A = open({ observe, noted, moved, send });
187
189
  for (const m of told()) A.frame(m);
188
190
  /** The room stops and starts again as a deploy restarts it: a new epoch, everybody away until they are back. */
189
191
  const restart = () => {
190
192
  const bytes = A.save(); const epoch = A.epoch + 1; A.stop();
191
- A = open({ observe, noted, send, restore: bytes, restoreEpoch: epoch });
193
+ A = open({ observe, noted, moved, send, restore: bytes, restoreEpoch: epoch });
192
194
  for (const m of told()) A.frame(m);
193
195
  // Everybody is back at once, but for the highest seat, which is a second late.
194
196
  const top = Math.max(...seated.keys());
@@ -342,6 +344,17 @@ function play(c, companions, allow) {
342
344
  const stats = A.core.stats; const cut = stats.ticksCut; const held = stats.held;
343
345
  A.tickNow();
344
346
  units += A.core.stats.tickUnits;
347
+ for (const [name, tick, input, before, after] of movesToCheck.splice(0)) {
348
+ const kind = c.kinds.find(k => k.name === name);
349
+ moveTick = tick;
350
+ const result = L.P.stepMove(kind.move, before, input, moveCtx.get(name), Math.max(1, Math.floor(budget / 4)), kind.motion, c.dims,
351
+ error => { fault ??= `${siteOf(name, 'move')} ${name}.move: prediction failed: ${error.message}.${when()}`; });
352
+ units += result.used;
353
+ const r = kind.body.radius, bounds = c.map.bounds;
354
+ result.pos = L.P.vec3({ x: Math.max(bounds.min.x + r, Math.min(bounds.max.x - r, result.pos.x)), y: Math.max(bounds.min.y + r, Math.min(bounds.max.y - r, result.pos.y)), z: result.pos.z }, c.dims);
355
+ delete result.used;
356
+ if (JSON.stringify(result) !== JSON.stringify(after)) fault ??= `${siteOf(name, 'move')} ${name}.move: prediction and the server produced different bodies from the same input.${when()}`;
357
+ }
345
358
  if (A.core.stats.tickUnits > peak) peak = A.core.stats.tickUnits;
346
359
  if (A.core.stats.maxUnits > most) { most = A.core.stats.maxUnits; worst = A.core.stats.worst; }
347
360
  const facts = A.facts();
package/lib/scaffold.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { APPS_MIGRATION, APPS_MIGRATION_FILE } from '../worker/app-records.mjs';
2
+ import { PURCHASE_MIGRATION, PURCHASE_MIGRATION_FILE, PURCHASE_STATE, PURCHASE_STATE_FILE } from '../worker/purchase-schema.mjs';
2
3
  /**
3
4
  * `homie-studio new <folder> --name "<Studio Name>"` — a studio is ONE
4
5
  * monorepo: a visible folder the person opens in Claude or Codex.
@@ -65,7 +66,7 @@ export function slugify(name) {
65
66
  * No `database_id` until one is known: Wrangler (4.45.0+) and the Deploy to Cloudflare flow create the database
66
67
  * the binding names and keep it linked.
67
68
  */
68
- export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, triggers = null }) {
69
+ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layout = 'root', ai = false, routes = null, paidParts = false, partsRateLimit = null, triggers = null }) {
69
70
  const at = layout === 'site' ? { schema: '../node_modules', main: 'src/worker.mjs', dist: './dist', migrations: 'migrations' }
70
71
  : { schema: 'node_modules', main: 'site/src/worker.mjs', dist: './site/dist', migrations: 'site/migrations' };
71
72
  const rooms = [{ name: 'TABLE', class_name: 'Table' }, { name: 'LOBBY', class_name: 'Lobby' }];
@@ -79,19 +80,20 @@ export function wranglerConfig({ worker, name, d1, d1Id = null, r2 = null, layou
79
80
  // subdomain) reaches it instead of failing with 1042.
80
81
  // `disallow_eval_during_startup`: no code is made from a string while the Worker loads (Cloudflare never allows it
81
82
  // later). A game's rules run in this Worker (NETPLAY.md section 29), and nothing in them is ever made from text.
82
- compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup'],
83
+ compatibility_flags: ['global_fetch_strictly_public', 'disallow_eval_during_startup', ...(paidParts ? ['nodejs_compat'] : [])],
84
+ ...(paidParts ? { alias: { '@homie-rocks/studio/worker': '@homie-rocks/studio/worker/selling' }, triggers: { crons: ['*/5 * * * *'] }, ratelimits: [partsRateLimit ?? { name: 'PURCHASE_RATE_LIMITER', namespace_id: '1001', simple: { limit: 60, period: 60 } }] } : {}),
83
85
  workers_dev: true,
84
86
  preview_urls: true,
85
87
  // The studio's own custom-domain and exact-host routes, as its owner wrote them: this file is written again by
86
88
  // every deploy, and one written without them sends the studio's domain back to whatever else the zone routes
87
89
  // (lib/routes.mjs). A studio without routes gets no key at all.
88
90
  ...(routes?.length ? { routes } : {}),
89
- ...(triggers ? { triggers } : {}),
91
+ ...(triggers ? { triggers: paidParts ? { ...triggers, crons: [...new Set([...(triggers.crons ?? []), '*/5 * * * *'])] } : triggers } : {}),
90
92
  assets: { directory: at.dist, binding: 'ASSETS', run_worker_first: true },
91
93
  durable_objects: { bindings: rooms },
92
94
  migrations: [{ tag: 'v1', new_sqlite_classes: ['Table', 'Lobby'] }],
93
95
  d1_databases: [{ binding: 'DB', database_name: d1, ...(d1Id ? { database_id: d1Id } : {}), migrations_dir: at.migrations }],
94
- ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }] } : {}),
96
+ ...(r2 ? { r2_buckets: [{ binding: 'MEDIA', bucket_name: r2 }, ...(paidParts ? [{ binding: 'PURCHASE_MEDIA', bucket_name: `${r2.slice(0, 52)}-purchases` }] : [])] } : {}),
95
97
  // Workers AI, only when a server's AI guides think with it (agents_brain workers-ai; deploy adds it, 0.17.0).
96
98
  // Never in Previews: they do not inherit it, so a Preview's guides answer from the game's script.
97
99
  ...(ai ? { ai: { binding: 'AI' } } : {}),
@@ -747,6 +749,8 @@ export { default, Table, Lobby } from '@homie-rocks/studio/worker';
747
749
  [`site/migrations/${SERVERS_MIGRATION_FILE}`]: SERVERS_MIGRATION,
748
750
  [`site/migrations/${CHAT_MIGRATION_FILE}`]: CHAT_MIGRATION,
749
751
  [`site/migrations/${SHOP_MIGRATION_FILE}`]: SHOP_MIGRATION,
752
+ [`site/migrations/${PURCHASE_MIGRATION_FILE}`]: PURCHASE_MIGRATION,
753
+ [`site/migrations/${PURCHASE_STATE_FILE}`]: PURCHASE_STATE,
750
754
  [`site/migrations/${SHOP_RESERVATIONS_FILE}`]: SHOP_RESERVATIONS,
751
755
  [`site/migrations/${SHOP_STATEMENTS_FILE}`]: SHOP_STATEMENTS,
752
756
  [`site/migrations/${SHOP_LINES_FILE}`]: SHOP_LINES,
@@ -906,8 +910,24 @@ function ensureAppsMigration(root) {
906
910
  mkdirSync(dirname(file), { recursive: true }); writeFileSync(file, APPS_MIGRATION);
907
911
  return `site/migrations/${APPS_MIGRATION_FILE}`;
908
912
  }
913
+ export function ensurePartsMigration(root) {
914
+ const file = join(root, 'site', 'migrations', PURCHASE_MIGRATION_FILE);
915
+ if (existsSync(file)) return null;
916
+ mkdirSync(dirname(file), { recursive: true });
917
+ writeFileSync(file, PURCHASE_MIGRATION);
918
+ return `site/migrations/${PURCHASE_MIGRATION_FILE}`;
919
+ }
920
+
921
+ function ensurePartsStateMigration(root) {
922
+ const file = join(root, 'site', 'migrations', PURCHASE_STATE_FILE);
923
+ if (existsSync(file)) return null;
924
+ mkdirSync(dirname(file), { recursive: true });
925
+ writeFileSync(file, PURCHASE_STATE);
926
+ return `site/migrations/${PURCHASE_STATE_FILE}`;
927
+ }
928
+
909
929
  export function ensureMigrations(root) {
910
- return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root)].filter(Boolean);
930
+ return [ensureStatsMigration(root), ensurePlayersMigration(root), ensureOfficeMigration(root), ensureServersMigration(root), ensureChatMigration(root), ensureShopMigration(root), ensureLoungeMigration(root), ensureShopReservations(root), ensureShopStatements(root), ensureShopLines(root), ensureAppsMigration(root), ensurePartsMigration(root), ensurePartsStateMigration(root)].filter(Boolean);
911
931
  }
912
932
 
913
933
  /** What a migration file the template added is for, in a few words (deploy and dev say it). */
package/lib/shoot.mjs CHANGED
@@ -27,7 +27,7 @@
27
27
  *
28
28
  * `--preview` (no --url): THE FRAMES HALF WITHOUT THE WHOLE SITE. It starts the light preview server itself
29
29
  * (lib/preview.mjs: the one built game's files on 127.0.0.1, no Wrangler, no rooms), opens the game's own page there
30
- * and shoots it. The game plays alone, offline, with its bots, which is what pictures of the game itself want. The
30
+ * and shoots it. A game with offline play enabled plays alone, with its bots, which is what pictures of the game itself want. The
31
31
  * smoke is NOT RUN (there is no room to meet in: that half needs `dev`), and there is no loading cover to wait for.
32
32
  * The result says which build the frames are of (`build`: its hash, the one `build` printed) and that the page
33
33
  * really loaded that build's hashed bundle (`bundle`: lib/build.mjs bundleOf, never a guess at assets/main.js).
@@ -115,7 +115,7 @@ export const VIRTUAL_CLOCK = `(() => {
115
115
  window.__homieClock = {
116
116
  get frozen() { return frozen; },
117
117
  now: perfNow,
118
- freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; stepAnims(0); return vPerf; },
118
+ freeze() { if (frozen) return vPerf; /* a whole millisecond (never earlier than the last real reading): stepped time is then base + n steps exactly, with no float dust between a step asked for and the time handed out */ vPerf = Math.ceil(R.perf()); vDate = R.D.now(); frozen = true; runTimers(vPerf); stepAnims(0); return vPerf; },
119
119
  step(ms) {
120
120
  if (!frozen) this.freeze();
121
121
  const d = Math.max(0, Number(ms) || 0);
@@ -274,7 +274,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
274
274
  const inAll = (fn, arg) => Promise.all(all().map((f) => T(f.evaluate(fn, arg), 20_000)));
275
275
  const frozen = await inAll(() => (window.__homieClock ? (window.__homieClock.freeze(), true) : false));
276
276
  if (!frozen.some(Boolean)) { result.why = 'the virtual clock is not in the page (it was opened before the script could be injected)'; return result; }
277
- if (hold) await first.page.keyboard.down(hold).catch(() => {});
277
+ if (hold) {
278
+ // The play page owns the top-level focus; the game's key listener lives in its iframe.
279
+ if (gf) await T(gf.evaluate(() => window.focus()), 5000);
280
+ await first.page.keyboard.down(hold).catch(() => {});
281
+ }
278
282
  const rows = [];
279
283
  for (let n = 1; n <= frames; n++) {
280
284
  if (left() < 3000) { result.partial = `stopped at frame ${n - 1} of ${frames}: the ${Math.round(timeoutMs / 1000)} s bound was reached (a slow renderer; ask for fewer frames or a longer --timeout)`; break; }
@@ -287,6 +291,7 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
287
291
  rows.push({ n, file, virtualMs: +(n * (1000 / fps)).toFixed(3), clockMs: nows.find((v) => Number.isFinite(v)) ?? null, realMs: Date.now() - t, ...(await frameFacts(first) ?? {}) });
288
292
  }
289
293
  if (hold) await first.page.keyboard.up(hold).catch(() => {});
294
+ result.rules = gf ? await T(gf.evaluate(() => { const p = window.__homieNet?.probe; return typeof p?.hosted === 'function' ? { hosted: p.hosted(), status: p.status?.(), tick: p.tick?.() } : null; }), 5000) : null;
290
295
  result.frames = rows.length;
291
296
  result.realSeconds = +((Date.now() - started) / 1000).toFixed(1);
292
297
  result.virtualSeconds = +((rows.length * 1000) / fps / 1000).toFixed(3);
@@ -294,10 +299,11 @@ export async function shoot({ url, game, frames = 60, fps = 30, device = 'comput
294
299
  result.limits = 'Stepped: requestAnimationFrame, performance.now, Date, setTimeout, setInterval, CSS and Web animations, in the page and the game\'s frame. NOT stepped: the room\'s socket and its server clock (other players and the round arrive in real time), Web Audio\'s clock, <video>, workers. These frames show what was drawn; they are not a frame rate.';
295
300
  const probed = rows.some((r) => r.phase !== undefined);
296
301
  if (!probed) result.note = [result.note, 'the game exposes no port probe (exposePort): the frames carry no round phase or position'].filter(Boolean).join('; ');
297
- writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
298
302
  const smokeOk = result.smoke.ok !== false;
299
- result.ok = rows.length === frames && smokeOk;
300
- if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : result.partial;
303
+ const rulesOk = !result.rules || (result.rules.status === 'playing' && result.rules.tick > 0);
304
+ result.ok = rows.length === frames && smokeOk && rulesOk && !result.errors.length;
305
+ if (!result.ok) result.why = !smokeOk ? `smoke: ${result.smoke.why}` : !rulesOk ? 'The rules game is not playing. A server-only build needs its room: run homie-studio dev and shoot with --url (or explicitly enable room.offline for offline play).' : result.errors.length ? `game errors: ${result.errors.join('; ')}` : result.partial;
306
+ writeFileSync(join(out, 'shoot.json'), `${JSON.stringify({ v: 1, kind: 'homie-shoot', ...result, rows }, null, 1)}\n`);
301
307
  return result;
302
308
  } finally {
303
309
  if (served?.server) { served.server.closeAllConnections?.(); served.server.close(); }
@@ -3,10 +3,10 @@ import { createHash, randomUUID } from 'node:crypto';
3
3
  import { productIdOf, WEBHOOK_SECRET_SHAPE } from '../worker/stripe.mjs';
4
4
  import { defaultTaxCode } from '../worker/shop-rules.mjs';
5
5
  import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/shop-links.mjs';
6
- import { HOOK_EVENTS } from './shop.mjs';
6
+ import { HOOK_EVENTS, SHOP_HOOK_EVENTS } from './shop.mjs';
7
7
  const hash = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex');
8
8
  const same = (a, b) => Object.keys(a ?? {}).length === Object.keys(b).length && Object.entries(b).every(([k, v]) => a?.[k] === v);
9
- export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt, saveReceipt }) {
9
+ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, receipt, saveReceipt }) {
10
10
  const list = async (path, params = {}) => {
11
11
  const all = []; let cursor;
12
12
  do {
@@ -62,26 +62,35 @@ export async function syncPaymentLinks({ api, shop, slug, site, mode, w, receipt
62
62
  keepLinks.add(link.id);
63
63
  config.items.push({ item: item.id, id: link.id, url: link.url, revision: linkRevision });
64
64
  }
65
+ const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
66
+ const purchaseSecrets = {};
67
+ if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
68
+ const { generateKeyPair, exportJWK } = await import('jose');
69
+ const pair = await generateKeyPair('Ed25519', { extractable: true });
70
+ purchaseSecrets.PURCHASE_SIGNING_KEYS = JSON.stringify([await exportJWK(pair.privateKey)]);
71
+ }
72
+ const events = purchases?.config.offers.length ? HOOK_EVENTS : SHOP_HOOK_EVENTS;
65
73
  const endpoints = await list('/v1/webhook_endpoints');
66
74
  const url = `${site}/api/shop/hook`;
67
- const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && HOOK_EVENTS.every((event) => e.enabled_events?.includes(event)));
75
+ const current = endpoints.find((e) => e.id === receipt?.endpoint && e.url === url && e.status === 'enabled' && events.every((event) => e.enabled_events?.includes(event)));
68
76
  let endpoint = current, secret;
69
77
  if (!endpoint || !has('STRIPE_WEBHOOK_SECRET')) {
70
78
  const pending = receipt?.url === url && receipt?.pending ? receipt.pending : randomUUID();
71
79
  saveReceipt({ ...receipt, pending, mode, url }); // Retry the same private creation response; never persist the secret.
72
- endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...HOOK_EVENTS], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
80
+ endpoint = await api('POST', '/v1/webhook_endpoints', { url, enabled_events: [...events], api_version: LINKS_STRIPE_VERSION, metadata: { homie: 'shop-v1' }, description: 'Studio shop' }, `webhook-${pending}`);
73
81
  if (!WEBHOOK_SECRET_SHAPE.test(endpoint.secret ?? '')) throw new Error('Missing signing secret');
74
82
  secret = endpoint.secret;
75
83
  }
76
- const fingerprint = hash(config);
84
+ const fingerprint = hash({config, purchases:purchases?.config});
77
85
  const unchanged = current && !secret && receipt?.fingerprint === fingerprint && has('STRIPE_SHOP_LINKS') && !has('STRIPE_KEY');
78
86
  if (!unchanged) {
79
- const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
87
+ const saved = await w(['secret', 'bulk'], { input: JSON.stringify({ STRIPE_SHOP_LINKS: JSON.stringify(config), ...purchaseSecrets, ...(purchases && (purchases.config.offers.length || has('PURCHASE_LINKS')) ? { PURCHASE_LINKS: JSON.stringify(purchases.config) } : {}), ...(secret ? { STRIPE_WEBHOOK_SECRET: secret } : {}) }) });
80
88
  if (saved.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Cloudflare could not save the shop connection. Run shop connect again; existing webhooks remain active.' };
81
89
  // Explicitly running connect selects keyless; manual selects the existing keyed checkout.
82
90
  if (has('STRIPE_KEY') && (await w(['secret', 'delete', 'STRIPE_KEY'], { input: 'y\n' })).code !== 0) return { ok: false, needs: 'cloudflare', why: 'The links are ready; run shop connect again to finish removing the old checkout credential.' };
83
91
  saveReceipt({ mode, endpoint: endpoint.id, url, fingerprint, keyless: true });
84
92
  }
93
+ await purchases?.retire();
85
94
  // Retire only after the replacement configuration and signing secret are installed.
86
95
  for (const link of links.filter((p) => own(p) && p.active && !keepLinks.has(p.id))) await api('POST', `/v1/payment_links/${link.id}`, { active: false });
87
96
  for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });