@homie-rocks/studio 0.51.4 → 0.51.6

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 (94) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +16 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/guides/app/NEEDS.md +160 -0
  6. package/guides/app/SKILL.md +20 -4
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/parts/SKILL.md +1 -1
  18. package/guides/shop/SKILL.md +68 -77
  19. package/guides/sound/SKILL.md +70 -1
  20. package/guides/sound/scripts/player/sound.js +233 -0
  21. package/guides/studio-setup/SKILL.md +11 -27
  22. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  23. package/guides/style/SKILL.md +66 -3
  24. package/guides/video/SKILL.md +83 -0
  25. package/guides/video/references/DELIVERY.md +73 -0
  26. package/guides/video/references/HONESTY.md +40 -0
  27. package/guides/video/references/METHOD.md +144 -0
  28. package/guides/video/references/RECORD.md +137 -0
  29. package/guides/video/references/TRAILER.md +154 -0
  30. package/guides/video/references/examples/site-walkthrough.json +14 -0
  31. package/guides/video/references/examples/studio-play-phone.json +15 -0
  32. package/guides/video/references/examples/studio-play.json +17 -0
  33. package/guides/video/scripts/capture-game.mjs +175 -0
  34. package/guides/video/scripts/clock.js +136 -0
  35. package/guides/video/scripts/drawover/film.js +58 -0
  36. package/guides/video/scripts/drawover/index.html +9 -0
  37. package/guides/video/scripts/drawover/look.js +115 -0
  38. package/guides/video/scripts/drawover/shots.json +12 -0
  39. package/guides/video/scripts/lib/browser.mjs +87 -0
  40. package/guides/video/scripts/lib/frames.mjs +170 -0
  41. package/guides/video/scripts/lib/remix.mjs +159 -0
  42. package/guides/video/scripts/qa.mjs +127 -0
  43. package/guides/video/scripts/record-fixed.mjs +403 -0
  44. package/guides/video/scripts/record-page.mjs +477 -0
  45. package/guides/video/scripts/tap.js +108 -0
  46. package/guides/video/scripts/video.mjs +888 -0
  47. package/lib/app-needs.mjs +86 -18
  48. package/lib/app-ready.mjs +21 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/check.mjs +16 -7
  51. package/lib/cloudflare.mjs +6 -11
  52. package/lib/codex.mjs +7 -5
  53. package/lib/dev.mjs +1 -2
  54. package/lib/doctor.mjs +10 -54
  55. package/lib/mcp-tools.mjs +42 -21
  56. package/lib/mcp.mjs +1 -1
  57. package/lib/media-import.mjs +41 -0
  58. package/lib/media-workflows.mjs +13 -0
  59. package/lib/needs-headless.mjs +34 -0
  60. package/lib/official-media.mjs +15 -0
  61. package/lib/rules-build.mjs +13 -5
  62. package/lib/scaffold.mjs +2 -2
  63. package/lib/shop-links.mjs +81 -19
  64. package/lib/shop-listen.mjs +43 -0
  65. package/lib/shop-local.mjs +1 -1
  66. package/lib/shop-mcp.mjs +58 -0
  67. package/lib/stripe-connect.mjs +36 -15
  68. package/lib/template-history.json +87 -0
  69. package/lib/upgrade-media.mjs +13 -0
  70. package/lib/upgrade.mjs +10 -0
  71. package/package.json +1 -1
  72. package/parts/PARTS.md +1 -1
  73. package/rules/view.ts +4 -0
  74. package/shop/SHOP.md +110 -34
  75. package/worker/app-records.mjs +1 -0
  76. package/worker/build-id.mjs +1 -1
  77. package/worker/event-resources.mjs +9 -3
  78. package/worker/index.mjs +5 -1
  79. package/worker/local-test.mjs +4 -0
  80. package/worker/mcp-events.mjs +2 -2
  81. package/worker/mcp-tools.mjs +19 -1
  82. package/worker/mcp.mjs +1 -1
  83. package/worker/purchase-reconciliation.mjs +16 -1
  84. package/worker/shop-links.mjs +1 -1
  85. package/worker/shop-rules.mjs +3 -0
  86. package/worker/shop-subscriptions.mjs +2 -0
  87. package/worker/shop.mjs +58 -8
  88. package/worker/stripe-ownership.mjs +7 -0
  89. package/worker/version.mjs +1 -1
  90. package/guides/models/references/models.json +0 -175
  91. package/guides/music/scripts/lib/eleven.mjs +0 -260
  92. package/lib/projects-env.mjs +0 -68
  93. package/lib/projects.mjs +0 -311
  94. package/lib/provider-key.mjs +0 -21
package/lib/mcp-tools.mjs CHANGED
@@ -1,4 +1,3 @@
1
- import { providerKey } from './provider-key.mjs';
2
1
  /**
3
2
  * THE LOCAL MCP SERVER'S TOOLS (`homie-studio mcp`, lib/mcp.mjs): the studio toolkit as MCP tools, so the Claude
4
3
  * desktop app (through the Homie extension), Claude Code or any MCP client builds games in the SAME chat that shows
@@ -200,14 +199,14 @@ function needsInstall(ctx, root) {
200
199
 
201
200
  /* ------------------------------------------------------------------ the setup card */
202
201
 
203
- async function statusOf(ctx, root, { fresh = false } = {}) {
202
+ async function statusOf(ctx, root, { fresh = false, integrations = {} } = {}) {
204
203
  const key = root ?? '';
205
- if (!fresh && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
204
+ if (!fresh && !Object.keys(integrations).length && ctx.status && ctx.status.key === key && Date.now() - ctx.status.at < 20_000) return ctx.status.value;
206
205
  const node = findNode({ fresh });
207
206
  // Claude Code's own status line is not this host's: its row stays out (CLAUDECODE says the server runs inside it).
208
207
  const env = toolEnv();
209
208
  delete env.CLAUDECODE;
210
- const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', node: node?.version ?? '0', env });
209
+ const value = await setupStatus({ cwd: root ?? ctx.studiosDir ?? homedir(), connector: 'yes', integrations, node: node?.version ?? '0', env });
211
210
  const row = value.rows.find((r) => r.id === 'node');
212
211
  if (row) {
213
212
  if (!node) { row.detail = 'not found on this computer (22 or newer is needed)'; row.state = 'missing'; }
@@ -240,8 +239,8 @@ function checklist(ctx, root) {
240
239
  return steps.map((label, n) => ({ n, label, state: done[n] ? 'done' : n === now ? 'now' : 'todo' }));
241
240
  }
242
241
 
243
- async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null } = {}) {
244
- const status = await statusOf(ctx, root, { fresh });
242
+ async function setupCard(ctx, root, { fresh = false, made = null, install = null, wanted = null, integrations = {} } = {}) {
243
+ const status = await statusOf(ctx, root, { fresh, integrations });
245
244
  const studios = ctx.studios().map(({ root: r, ...s }) => s);
246
245
  const current = root ? ctx.describe(root) : null;
247
246
  const running = root ? runningJobs(root).find((j) => j.label.startsWith('npm install')) : null;
@@ -814,13 +813,13 @@ export function toolDefs(ctx, avail = {}) {
814
813
  {
815
814
  name: 'setup_status', title: 'Setup status',
816
815
  description: 'Step 0 of a new studio, and any time the person asks what they need or whether they are set up: what this computer and their accounts have (Node, Cloudflare signed in and email verified, Chrome for the checks, ffmpeg, and the optional GitHub, ElevenLabs and fal), what each unlocks, and the exact fix; the studios in the studios folder; and the new-studio checklist with where they are on it. For a studio that is not made yet, pass its name as studio: the checklist is then the new studio\'s, never another studio\'s that happens to be in the folder. Read-only, a few seconds. Optional rows never block anything.',
817
- inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' } } },
816
+ inputSchema: { type: 'object', properties: { studio: str('Optional: which studio (its folder name or name), or the name of a new studio that is not made yet; else the one in use'), fresh: { type: 'boolean', description: 'Check again now instead of the last 20 s' }, integrations: { type: 'object', description: 'Session evidence from official MCP tools: connected only after a successful authenticated call; unavailable when absent, otherwise unknown. Never credentials.', properties: Object.fromEntries(['fal','elevenlabs','cloudflare'].map(id => [id, { type: 'string', enum: ['connected','unavailable','unknown'] }])) } } },
818
817
  annotations: { title: 'Setup status', ...RO }, _meta: ui(UI.setup),
819
818
  run: async (a) => {
820
819
  // A name with no studio of it yet is the studio about to be made: its own checklist, from step 1.
821
820
  const named = String(a.studio ?? '').trim().slice(0, 60);
822
- if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, wanted: named });
823
- return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true });
821
+ if (named && !ctx.knows(named)) return setupCard(ctx, null, { fresh: a.fresh === true, integrations: a.integrations, wanted: named });
822
+ return setupCard(ctx, ctx.root(a.studio, { need: false }), { fresh: a.fresh === true, integrations: a.integrations });
824
823
  },
825
824
  },
826
825
  {
@@ -1031,13 +1030,13 @@ export function toolDefs(ctx, avail = {}) {
1031
1030
  },
1032
1031
  {
1033
1032
  name: 'build', title: 'Build the site',
1034
- description: 'Bundle every game or app and the site into site/dist. Rules builds always run strict types, the guard and deterministic generated play, including save/restore and prediction checks. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
1035
- inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), ...STUDIO_ARG } },
1033
+ description: 'Bundle every game or app and the site into site/dist. Rules builds run strict types, the guard and deterministic generated play. quick:true skips generated play and snapshot smoke checks for local iteration; full build before release. Read full diagnostics and repair the named handler; info lines do not fail. Never bypass checks or switch to browser hosting to escape a failure. A running preview shows the new build on reload.',
1034
+ inputSchema: { type: 'object', properties: { game: str('Optional: only this game'), quick:{type:'boolean'}, ...STUDIO_ARG } },
1036
1035
  annotations: { title: 'Build', ...RW },
1037
1036
  run: async (a) => {
1038
1037
  const root = ctx.root(a.studio);
1039
1038
  const need = needsInstall(ctx, root); if (need) return need;
1040
- const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : [])]);
1039
+ const r = await cli(ctx, root, 'build', ['build', ...(a.game ? [String(a.game)] : []), ...(a.quick?['--quick']:[])]);
1041
1040
  if (!r.ended) return stillRunning(r.job, 'The build');
1042
1041
  if (r.job.code !== 0) return fail(`The build failed: ${whyOf(r.job)}`);
1043
1042
  const g = r.result?.games ?? [];
@@ -1177,9 +1176,31 @@ export function toolDefs(ctx, avail = {}) {
1177
1176
  return startRun(ctx, root, { kind: 'deploy', game: null, title: `${studio.name}: online`, steps: [{ label: 'deploy', args: ['deploy', ...(a.ownRoute === true ? ['--own-route'] : [])] }] });
1178
1177
  },
1179
1178
  },
1179
+ {
1180
+ name: 'stripe_shop_plan', title: 'Plan or verify the Stripe shop',
1181
+ description: 'Default setup with Stripe official MCP. Supply public API observations as {path,params,body}, account ID, mode, and complete booking slots. Returns the exact next read/write with metadata and idempotency. Execute via Stripe stripe_api_search/details/read/write, clear observations after writes, repeat until verified. verify:true saves the public config for stripe_webhook. Never pass webhook creation responses or secrets.',
1182
+ inputSchema: {type:'object',properties:{...STUDIO_ARG,account:{type:'string'},mode:{enum:['test','live']},url:{type:'string'},slots:{type:'array',items:{type:'object'}},observations:{type:'array',items:{type:'object'}},verify:{type:'boolean'}}},
1183
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:false},
1184
+ run:async a=>{
1185
+ const {shopMcpPlan,saveMcpVerification}=await import('./shop-mcp.mjs');
1186
+ const result=await (a.verify?saveMcpVerification:shopMcpPlan)(ctx.root(a.studio),a);
1187
+ return ok(JSON.stringify(result),result);
1188
+ },
1189
+ },
1190
+ {
1191
+ name:'stripe_webhook',title:'Install Stripe webhook privately',
1192
+ description:'After stripe_shop_plan verify:true, create/install only the webhook and verified public links. Uses a separate official CLI browser approval if needed; never shows secrets. Follow studio_job. Never create a webhook through model-visible Stripe MCP.',
1193
+ inputSchema:{type:'object',properties:{...STUDIO_ARG,live:{type:'boolean'},renew:{type:'boolean'}}},
1194
+ annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1195
+ run:async a=>{
1196
+ const job=cliJob(ctx.root(a.studio),'Private Stripe webhook',['shop','webhook',...(a.live?['--live']:[]),...(a.renew?['--renew']:[])],{cli:join(PACKAGE_ROOT,'bin','homie-studio.mjs'),keep:false});
1197
+ await waitJob(job,Math.min(ctx.waitMs,1000));
1198
+ return ok('Follow studio_job; approve Stripe in the browser if requested.',{kind:'job',...jobView(job)});
1199
+ },
1200
+ },
1180
1201
  {
1181
1202
  name:'stripe_bookings_sync', title:'Sync published booking slots to Stripe',
1182
- description:'Owner AI: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1203
+ description:'CLI alternative to stripe_shop_plan with Stripe official MCP: after reading published slot records, supply the complete slot manifest. Creates/updates Stripe Payment Links with completed-session capacity limits through the approved CLI profile. Before moving a booking, sync target active:false to deactivate and expire open sessions. After the versioned staff move, sync current confirmed counts for affected slots to restore the remaining budget. Removed slots deactivate and expire open sessions. No Worker API key. Follow studio_job. Never increase capacity merely to reopen a full slot.',
1183
1204
  inputSchema:{type:'object',properties:{...STUDIO_ARG,slots:{type:'array',items:{type:'object',properties:{app:{type:'string'},collection:{type:'string'},slots:{type:'string'},slot:{type:'string'},item:{type:'string'},capacity:{type:'integer',minimum:0},confirmed:{type:'integer',minimum:0},active:{type:'boolean'}},required:['app','collection','slots','slot','item','capacity'],additionalProperties:false}},live:{type:'boolean'}},required:['slots']},
1184
1205
  annotations:{readOnlyHint:false,destructiveHint:false,openWorldHint:true},
1185
1206
  run:async a=>{
@@ -1193,12 +1214,12 @@ export function toolDefs(ctx, avail = {}) {
1193
1214
  },
1194
1215
  {
1195
1216
  name: 'stripe_login', title: 'Connect the studio to Stripe',
1196
- description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. An existing key supports fuller checkout; manual only if explicitly chosen for those features. live only when the owner says go live.',
1197
- inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' }, manual: { type: 'boolean' } } },
1217
+ description: 'Connect and sync shop.json through Stripe’s official CLI with one browser approval if needed. Creates Products, Prices, Payment Links and the webhook; installs only links and signing secret, no API key in the Worker. Repeat after edits. Follow studio_job. Alternative to the default Stripe official MCP plan and private stripe_webhook flow. live only when the owner says go live.',
1218
+ inputSchema: { type: 'object', properties: { ...STUDIO_ARG, live: { type: 'boolean' }, renew: { type: 'boolean' } } },
1198
1219
  annotations: { title: 'Connect Stripe', readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1199
1220
  run: async (a) => {
1200
1221
  const root = ctx.root(a.studio);
1201
- const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : []), ...(a.manual ? ['--manual'] : [])];
1222
+ const args = ['shop', 'connect', ...(a.live ? ['--live'] : []), ...(a.renew ? ['--renew'] : [])];
1202
1223
  const job = cliJob(root, 'Stripe connection', args, { cli: join(PACKAGE_ROOT, 'bin', 'homie-studio.mjs'), keep: false });
1203
1224
  const ended = await waitJob(job, Math.min(ctx.waitMs, 1000));
1204
1225
  ctx.status = null;
@@ -1496,13 +1517,13 @@ export function toolDefs(ctx, avail = {}) {
1496
1517
  },
1497
1518
  });
1498
1519
  }
1499
- // The media skills' own scripts, where their provider (or the free tool they need) is set up on this computer.
1500
- for (const [name, title, needs] of [['music', 'Music (ElevenLabs)', 'elevenlabs'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: generated 3D props (paid, on your fal account)', 'node']]) {
1520
+ // Local media processing; provider tools are discovered by the AI host.
1521
+ for (const [name, title, needs] of [['music', 'Music preparation', 'ffmpeg'], ['sound', 'Sound effects and a theme (free)', 'ffmpeg'], ['art', 'Art: covers, frames, painted art', 'ffmpeg'], ['video', 'Video: trailers and clips', 'ffmpeg'], ['models', 'Models: import completed 3D assets', 'node']]) {
1501
1522
  const script = ctx.skillsDir ? join(ctx.skillsDir, name, 'scripts', `${name}.mjs`) : null;
1502
1523
  if (!script || !existsSync(script) || !avail[needs]) continue;
1503
1524
  tools.push({
1504
1525
  name, title,
1505
- description: `The ${name} guide's own script, in the studio, by its words (studio_guide { "topic": "${name}" } says which, in what order, and what costs money): e.g. ["check"]${name === 'music' ? ', ["quote","--seconds","30"]' : name === 'sound' ? ', ["sfx","pickup"]' : ''}. Paid steps are quoted first and capped by the budget the person agreed to. Long renders hand back a job.`,
1526
+ description: `Local ${name} preparation only; read studio_guide for the workflow. Generation, pricing and provider connections use the official provider MCP in the AI client.`,
1506
1527
  inputSchema: { type: 'object', properties: { args: { type: 'array', items: { type: 'string' }, description: 'The script\'s words' }, ...STUDIO_ARG }, required: ['args'] },
1507
1528
  annotations: { title, readOnlyHint: false, destructiveHint: false, openWorldHint: true },
1508
1529
  run: async (a) => {
@@ -1523,9 +1544,9 @@ export function toolDefs(ctx, avail = {}) {
1523
1544
  return tools;
1524
1545
  }
1525
1546
 
1526
- /** What the media tools need, cheaply (no network): ffmpeg on the PATH, ElevenLabs' CLI or key. */
1547
+ /** Local processing availability only; provider connections belong to the AI host. */
1527
1548
  export function availability(env = process.env) {
1528
- return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')), elevenlabs: Boolean(providerKey('elevenlabs', env).key || which(process.platform === 'win32' ? 'elevenlabs.exe' : 'elevenlabs')) };
1549
+ return { node: true, ffmpeg: Boolean(which(process.platform === 'win32' ? 'ffmpeg.exe' : 'ffmpeg')) };
1529
1550
  }
1530
1551
 
1531
1552
  /** The codex page small enough to hand a card (a tool result over ~150,000 characters is not shown). */
package/lib/mcp.mjs CHANGED
@@ -37,7 +37,7 @@ Carry the person's request through to a working result without a lesson or an in
37
37
  Run setup_status (for a studio that is not made yet, pass its name as studio), studio_scaffold (NO game until one is requested), then build the requested thing and check it. A demo and a small-change lesson are optional only when requested. Look for existing engine mechanisms and parts_find before building systems. Use few, large edits: one file_edit carries every change to a file in its edits list. game_plan records your decisions; it does not require an interview. Install missing prerequisites yourself with setup_prepare; follow studio_job without asking the person to retry. Optional providers never block.
38
38
  New games and apps needing transient shared live state use rules plus view with room.host server. Read studio_guide topic game and its file RULES.md before writing rules: declared entity/shared state and events, guarded deterministic move for immediate local prediction, and a browser view for input and rendering. Read file REWRITE.md when an owner asks to move an old game's rules to the server; no automatic converter exists; existing netplay games keep building and running with their original 32-seat limit; larger rooms require a rewrite. Repair the full build diagnostics and prove the requested behavior in two browsers. Set players.max to the chosen room size, including hundreds; public connections use Gates. Choose room.view radiusM, precisionM, nearM and farHz explicitly and measure at the requested size. Private per-seat state is not delivered. Apps use authorized app records for lasting/private business data; room hosting is not staff permission.
39
39
  Show progress briefly and give the preview link as soon as it works. Only when going live was requested, studio_deploy with plan: true says what it creates and costs; cloudflare_login asks for browser approval once; then studio_deploy. Publish to the directory only when requested (offer once after going live). Honour an existing request to deploy or list; do not ask again except for a host's mandatory approval.
40
- For a shop, stripe_login (shop connect) syncs Products, Prices, Payment Links and the webhook through one Stripe browser approval. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
40
+ For a shop, default to stripe_shop_plan with Stripe official MCP for catalog and links, then stripe_webhook for private webhook installation. stripe_login (shop connect) is the CLI alternative. No human-handled key. Follow studio_job and the result's single next step; rerun after shop.json changes. Test purchase, webhook, grant and refund with stand-ins before going live. Live payments only when requested.
41
41
  For a custom domain already on Cloudflare, studio_domain prepares the exact hostname route using the existing sign-in; studio_deploy applies it and Cloudflare creates DNS and TLS. Never ask for zone or account ids. Keep workers.dev until a domain is requested.
42
42
 
43
43
  If studio_scaffold finds an earlier folder of the studio's name that is not a studio, ask the person whether to fold its premise in (studio_fold), and remove it only with a second yes.
@@ -0,0 +1,41 @@
1
+ /** Place already downloaded official MCP outputs. No provider networking, credentials or generation. */
2
+ import { createHash } from 'node:crypto';
3
+ import { copyFileSync, existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
4
+ import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
5
+ function inside(root, name) {
6
+ const path = resolve(root, name), rel = relative(root, path);
7
+ if (!rel || isAbsolute(rel) || rel === '..' || rel.startsWith(`..${sep}`)) throw new Error('asset paths must stay inside the studio');
8
+ let ancestor = path;
9
+ while (!existsSync(ancestor)) ancestor = dirname(ancestor);
10
+ const actual = relative(realpathSync(root), realpathSync(ancestor));
11
+ if (isAbsolute(actual) || actual === '..' || actual.startsWith(`..${sep}`)) throw new Error('symlink leaves studio');
12
+ return path;
13
+ }
14
+ export function importMedia(root, { file, receipt, out, usedBy }) {
15
+ if (!existsSync(resolve(root, 'studio.json'))) throw new Error('not a studio');
16
+ const r = typeof receipt === 'string' ? JSON.parse(readFileSync(receipt, 'utf8')) : receipt;
17
+ for (const key of ['provider','tool','model','requestId','prompt','styleRevision','rights','cost']) if (r?.[key] === undefined) throw new Error(`receipt needs ${key}`);
18
+ if (!['fal','elevenlabs'].includes(r.provider) || !r.requestId || !r.tool || !r.model || !r.prompt) throw new Error('incomplete official MCP provenance');
19
+ if (!r.rights?.source || !r.rights?.checkedAt || typeof r.rights?.commercial !== 'boolean') throw new Error('record rights source, date and commercial permission');
20
+ if (!['estimate','actual','unknown'].includes(r.cost?.status) || (r.cost.status !== 'unknown' && !(Number.isFinite(r.cost.amount) && r.cost.amount >= 0 && r.cost.unit))) throw new Error('cost must be actual, estimate or explicitly unknown');
21
+ const target = inside(root, out), use = inside(root, usedBy);
22
+ const bytes = readFileSync(file);
23
+ if (!bytes.length) throw new Error('empty asset');
24
+ const hash = createHash('sha256').update(bytes).digest('hex');
25
+ const registry = inside(root, 'media/provenance.json');
26
+ const records = existsSync(registry) ? JSON.parse(readFileSync(registry,'utf8')) : [];
27
+ const previous = records.find(x => x.provider === r.provider && x.requestId === r.requestId && x.output === out);
28
+ if (previous && previous.sha256 !== hash) throw new Error('same output identity has different bytes');
29
+ if (existsSync(target) && createHash('sha256').update(readFileSync(target)).digest('hex') !== hash) throw new Error('destination already contains a different asset');
30
+ // Store only allowlisted provenance; raw tool responses may contain temporary signed URLs.
31
+ const record = { provider:r.provider, tool:r.tool, model:r.model, requestId:r.requestId, prompt:r.prompt, styleRevision:r.styleRevision,
32
+ rights:{ source:r.rights.source, checkedAt:r.rights.checkedAt, commercial:r.rights.commercial, note:r.rights.note ?? null },
33
+ cost:{ status:r.cost.status, amount:r.cost.amount ?? null, unit:r.cost.unit ?? null }, fixture:r.fixture === true,
34
+ output:out, usedBy, sha256:hash, bytes:bytes.length };
35
+ // Static reference evidence is not a runtime claim. Skills must also inspect the running scene.
36
+ const reference = relative(dirname(use), target).split(sep).join('/');
37
+ if (!readFileSync(use,'utf8').includes(reference)) throw new Error(`consumer does not reference ${reference}`);
38
+ mkdirSync(dirname(target),{recursive:true}); copyFileSync(file,target);
39
+ if (!previous) { records.push(record); mkdirSync(dirname(registry),{recursive:true}); writeFileSync(registry,JSON.stringify(records,null,2)+'\n'); }
40
+ return { ok:true, command:'media import', artifact:out, provenance:'media/provenance.json', sha256:hash, already:Boolean(previous), usage:'static reference verified; inspect the running scene' };
41
+ }
@@ -0,0 +1,13 @@
1
+ /** Declarative tool references for Homie's media skills; host namespaces are resolved by the AI. */
2
+ export const MEDIA_WORKFLOWS = Object.freeze({
3
+ art: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result','upload_file'] },
4
+ models: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','submit_job','check_job','get_job_result'] },
5
+ video: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','submit_job','check_job','get_job_result'] },
6
+ style: { provider:'fal', tools:['recommend_model','get_model_schema','get_pricing','run_model','check_job','get_job_result'] },
7
+ music: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
8
+ sound: { provider:'elevenlabs', tools:['creative_get_flow_node_types','creative_get_model_guide','creative_get_flow_run_status'], generation:'discover from connected workspace schema' },
9
+ });
10
+ export function validateMediaTools(workflow, tools) {
11
+ const available=new Set(tools.map(t=>t.name));
12
+ return {ready:workflow.tools.every(t=>available.has(t)), missing:workflow.tools.filter(t=>!available.has(t))};
13
+ }
@@ -0,0 +1,34 @@
1
+ import {localCookie} from './local-test.mjs';
2
+
3
+ /** Each action has an explicit local identity, using the real MCP transport and permissions. */
4
+ export function needsHeadless({url,secret,app,meta,scope}) {
5
+ return async (item,role) => {
6
+ if(!secret)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
7
+ if(!role || (role!=='owner'&&!Object.hasOwn(meta.roles??{},role)))throw new Error(`Name a declared role (or owner) for headless proof; got ${role??'none'}`);
8
+ const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
9
+ const client=new Client({name:'Homie needs check',version:'1.0.0'});
10
+ const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
11
+ try {
12
+ await client.connect(transport);
13
+ let call=item.mcp;
14
+ if(item.records)call={tool:'app_records',arguments:{...item.records,app,role,operation:'read'}};
15
+ if(item.schedule)call={tool:'studio_schedule_fire',arguments:item.schedule};
16
+ if(call?.method) {
17
+ if(call.method==='resources/read')return await client.readResource(call.params);
18
+ // The extension's JSON-RPC result is intentionally preserved for path assertions.
19
+ if(['events/list','events/poll'].includes(call.method)) {
20
+ const {z}=await import('zod');
21
+ return await client.request({method:call.method,params:call.params??{}},z.object({}).passthrough(),{timeout:10000});
22
+ }
23
+ throw new Error(`Unsupported MCP proof method ${call.method}; use tools, resources/read, events/list or events/poll`);
24
+ }
25
+ if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
26
+ const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
27
+ if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
28
+ if(result.structuredContent!==undefined)return result.structuredContent;
29
+ const content=result.content?.filter(c=>c.type==='text');
30
+ if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
31
+ return result;
32
+ } finally {await client.close();}
33
+ };
34
+ }
@@ -0,0 +1,15 @@
1
+ /** Provider connections belong to the AI host. Never inspect credentials or host configuration. */
2
+ export const INTEGRATIONS = Object.freeze({
3
+ fal: { label: 'fal', url: 'https://mcp.fal.ai/mcp-relay', connectUrl: 'https://fal.ai/docs/documentation/setting-up/mcp', unlocks: 'Images, 3D and generated video', connect: 'Install the official fal plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once.' },
4
+ elevenlabs: { label: 'ElevenLabs', url: 'https://api.elevenlabs.io/v1/mcp', connectUrl: 'https://elevenlabs.io/mcp', unlocks: 'Music, speech and sound where exposed by the connected MCP', connect: 'Install elevenlabs/plugin in your AI client, or add this remote MCP URL, then approve its browser sign-in once. Discover the workspace tools before promising a media capability.' },
5
+ cloudflare: { label: 'Cloudflare MCP', url: 'https://mcp.cloudflare.com/mcp', connectUrl: 'https://github.com/cloudflare/skills', unlocks: 'Inspect Workers Issues, logs and D1 through official tools', connect: 'Connect the official Cloudflare plugin or this MCP URL and approve account access in the browser. Deploys separately use Wrangler login (cloudflare_login).' },
6
+ });
7
+ export function officialConnections(evidence = {}) {
8
+ return Object.entries(INTEGRATIONS).map(([provider, p]) => {
9
+ const connected = evidence[provider] === 'connected';
10
+ return { id: provider === 'cloudflare' ? 'cloudflare-mcp' : provider, label: p.label, need: 'optional', state: connected ? 'ok' : 'optional',
11
+ connection: connected ? 'connected' : evidence[provider] === 'unavailable' ? 'unavailable' : 'unknown',
12
+ detail: connected ? 'Official MCP connected (authenticated call reported by this AI session)' : evidence[provider] === 'unavailable' ? 'Official MCP unavailable in this AI session' : 'Connection unknown: the AI host must discover tools and verify an authenticated read',
13
+ unlocks: p.unlocks, fix: connected ? null : { who: 'person', open: p.connectUrl, say: `${p.connect} MCP URL: ${p.url}` } };
14
+ });
15
+ }
@@ -141,7 +141,8 @@ export function smokeRun(H, compiled, id, { timer = () => { const t = process.cp
141
141
  * Returns { code, tune, map, settings, seats, stateHash, schema, publicTune, build, files, rounds, units, tickUnits, check }.
142
142
  * `longCheck`: eight times the generated play (`homie-studio build --long-check`).
143
143
  */
144
- export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false } = {}) {
144
+ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck = false, quick = false } = {}) {
145
+ log(`${g.id}: checking rules and types${quick ? ' (quick rebuild; play checks skipped)' : ''}`);
145
146
  const guarded = await guardRules(esbuild, root, g.dir);
146
147
  if (!guarded.ok) throw new Error(`${g.kind === 'app' ? 'apps' : 'games'}/${g.id}: its rules were refused.\n${guarded.problems.slice(0, 20).map((p) => ` ${problemLine(p)}`).join('\n')}${guarded.problems.length > 20 ? `\n … and ${guarded.problems.length - 20} more` : ''}`);
147
148
  const tune = readJson(join(g.dir, 'tunables.json')) ?? {};
@@ -157,8 +158,14 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
157
158
  const bundle = await loadRules(esbuild, root, g.id, guarded.code, { bundleOnly: true, physics: Boolean(map.physics) });
158
159
  const data = { id: g.id, tune, map, room: g.room, seats, vocab: readJson(join(g.dir, 'agents.json')), sites: guarded.sites };
159
160
  // The declarations first, for the type check: a wrong program is named by the compiler before anything is played.
160
- await typecheckRules(root, g, await runRulesWorker(bundle, data, { declarationsOnly: true }), tune, { log });
161
- const checked = await runRulesWorker(bundle, data, { allowance: longCheck ? LONG_ALLOWANCE : ALLOWANCE });
161
+ const declarations=await runRulesWorker(bundle,data,{declarationsOnly:true});
162
+ await typecheckRules(root,g,declarations,tune,{log});
163
+ log(`${g.id}: ${quick?'skipping generated play':'running generated rules play; use build --quick for an iteration without play checks'}`);
164
+ const heartbeat=setInterval(()=>log(`${g.id}: rules play still running (${Math.round((Date.now()-started)/1000)}s)`),15000);
165
+ const started=Date.now();
166
+ let checked;
167
+ try { checked=quick?{...declarations,information:[],plays:[],ticks:0,roundsPlayed:0,restores:0,units:0,maxUnits:0,maxTickUnits:0,largestSaveBytes:0}:await runRulesWorker(bundle,data,{allowance:longCheck?LONG_ALLOWANCE:ALLOWANCE}); }
168
+ finally {clearInterval(heartbeat);}
162
169
  for (const p of checked.problems) log(`warning: ${g.kind === 'app' ? 'apps' : 'games'}/${g.id}/${g.kind === 'app' ? 'app' : 'game'}.json: ${p}`);
163
170
  for (const line of checked.information) log(`info: ${line}`);
164
171
  const { settings, schema, publicTune, rounds, stateHash } = checked;
@@ -168,7 +175,8 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
168
175
  const { def, R, H, initializePhysics } = await loadRules(esbuild, root, g.id, guarded.code, {physics: Boolean(map.physics)});
169
176
  if (initializePhysics) await initializePhysics(readFileSync(new URL(import.meta.resolve('@homie-rocks/physics/vendor/rapier.wasm'))));
170
177
  const compiled = R.compileRules(def, { tune, map: R.compileMap(map, map.name), settings, seats });
171
- const stats = smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
178
+ log(`${g.id}: ${quick?'skipping':'running'} rules snapshot smoke check`);
179
+ const stats = quick ? {snapshotBytes:null,checkpointBytes:null,frameBytes:{},frameRates:{}} : smokeRun(H, compiled, g.id, { timer: () => 0, vocab: data.vocab });
172
180
  const snapshotCap = capOf('snap', seats); const checkpointCap = capOf('ckpt', seats);
173
181
  const exceeded = RULES_FRAMES.flatMap(t => {
174
182
  const bytes = stats.frameBytes[t] ?? 0; const cap = capOf(t, seats);
@@ -187,7 +195,7 @@ export async function prepareRules(esbuild, root, g, { log = () => {}, longCheck
187
195
  build: createHash('sha256').update(guarded.code).update(JSON.stringify(built)).digest('hex'),
188
196
  snapshotCap, checkpointCap, snapshotBytes: stats.snapshotBytes, checkpointBytes: stats.checkpointBytes,
189
197
  units: checked.maxUnits, tickUnits: checked.maxTickUnits,
190
- check: { information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
198
+ check: { skipped: quick, information: checked.information, capacityTrial: trial, plays: checked.plays, ticks: checked.ticks, rounds: checked.roundsPlayed, restores: checked.restores, units: checked.units, largestSaveBytes: checked.largestSaveBytes },
191
199
  };
192
200
  }
193
201
 
package/lib/scaffold.mjs CHANGED
@@ -535,7 +535,7 @@ where it sells and for Stripe's terms. Homie takes no cut.
535
535
  player refund window covers items; a tip is never taken back by the player.
536
536
  - Flood settings are configurable: buyer attempts 6/minute, address attempts 600/minute,
537
537
  new guest buyers 600/hour. Stripe constraints, integer arithmetic, ownership and signatures protect payments.
538
- - \`shop check\` validates settings; \`stripe_login\` / \`shop connect\` starts Stripe browser approval.
538
+ - \`shop check\` validates settings; \`stripe_shop_plan\` plans and verifies Stripe official MCP operations; \`stripe_webhook\` installs the signing secret privately.
539
539
  Never ask a person for a key. Follow the result's next step: deploy first if needed, then rerun connect.
540
540
  The default syncs Products, Prices, Payment Links and the webhook through the approved Stripe CLI.
541
541
  The Worker stores only links and a signing secret; buying needs no API key or running AI.
@@ -719,7 +719,7 @@ site/.wrangler/
719
719
  .wrangler/
720
720
  .dev.vars
721
721
  .env
722
- # Stripe Projects (setup --via stripe-projects): credentials stay in its vault and the .env files it syncs, never in git.
722
+ # Provider-owned credential stores and local environment files never belong in git.
723
723
  .env.*
724
724
  .projects/vault/
725
725
  .projects/cache/
@@ -6,7 +6,8 @@ import { LINKS_STRIPE_VERSION, linkSettings, paymentLinkURL } from '../worker/sh
6
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({ root, api, shop, slug, site, mode, w, receipt, saveReceipt, bookingSlots = [], local = false }) {
9
+ export const matchesFields = (actual, expected) => Array.isArray(expected) ? Array.isArray(actual) && actual.length === expected.length && expected.every((v,i)=>matchesFields(actual[i],v)) : expected && typeof expected === 'object' ? Object.entries(expected).every(([k,v]) => matchesFields(actual?.[k],v)) : actual === expected;
10
+ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, bookingSlots = [], verifyObjects = false }) {
10
11
  const list = async (path, params = {}) => {
11
12
  const all = []; let cursor;
12
13
  do {
@@ -19,15 +20,16 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
19
20
  } while (cursor);
20
21
  return all;
21
22
  };
22
- if(local && mode!=='test')throw new Error('Local checkout requires Stripe test mode');
23
- const secrets = local ? {code:0,stdout:'[]'} : await w(['secret', 'list']);
24
- if (secrets.code !== 0) return { ok: false, needs: 'cloudflare', why: 'Connect Cloudflare and deploy the studio, then run shop connect again.' };
25
- const names = JSON.parse(secrets.stdout ?? secrets.out);
26
- const has = (name) => names.some((s) => s.name === name);
27
23
  const products = await list('/v1/products');
28
24
  const prices = await list('/v1/prices');
29
25
  const links = await list('/v1/payment_links');
30
26
  const own = (o) => o.metadata?.homie === 'shop-links-v1' && o.metadata?.studio === slug && o.metadata?.origin === site;
27
+ const verifyLink = async (link, params) => {
28
+ if (!verifyObjects) return;
29
+ const actual=await api('GET',`/v1/payment_links/${link.id}`,{expand:['line_items']});
30
+ const normalized={...actual,line_items:actual.line_items?.data?.map(line=>({...line,price:line.price?.id??line.price}))};
31
+ if(actual.id!==link.id || actual.active!==link.active || actual.url!==link.url || actual.line_items?.has_more===true || !matchesFields(actual.metadata,link.metadata) || !paymentLinkURL(actual.url) || !matchesFields(normalized,params)) throw new Error(`Stripe Payment Link ${link.id} differs from its planned fields. Restore its configuration through Stripe MCP before verifying.`);
32
+ };
31
33
  const config = { v: 1, mode, settings: linkSettings(shop), items: [] };
32
34
  const customText=shop.giving?.receiptText ? {custom_text:{submit:{message:shop.giving.receiptText}}} : {};
33
35
  const catalogPrices = new Map();
@@ -36,7 +38,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
36
38
  const id = await productIdOf(slug, item.id);
37
39
  keepProducts.add(id);
38
40
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
39
- const fields = { name: item.name, ...(item.blurb ? {description:item.blurb} : {}), tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
41
+ const fields = { name: item.name, description:item.blurb??'', tax_code: item.taxCode ?? defaultTaxCode(item), metadata, active: true };
40
42
  let product = products.find((p) => p.id === id);
41
43
  if (!product) product = await api('POST', '/v1/products', { id, ...fields }, `product-${hash(id)}`);
42
44
  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);
@@ -48,6 +50,8 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
48
50
  const revision = hash(priceFields);
49
51
  let price = prices.find((p) => own(p) && p.product === id && p.metadata?.revision === revision);
50
52
  if (!price) price = await api('POST', '/v1/prices', { ...priceFields, metadata: { ...metadata, revision } }, `price-${hash({ priceFields, metadata })}`);
53
+ if (verifyObjects && ((price.recurring?.interval_count??1)!==1 || Boolean(price.recurring)!==Boolean(item.interval) || Boolean(price.custom_unit_amount)!==(item.price==='choose'))) throw new Error(`Stripe Price ${price.id} has a different payment type from shop.json`);
54
+ if (verifyObjects && !matchesFields({...price, product:price.product?.id??price.product, ...(price.custom_unit_amount?{custom_unit_amount:{...price.custom_unit_amount,enabled:true}}:{})},priceFields)) throw new Error(`Stripe Price ${price.id} differs from shop.json; restore or archive it through Stripe MCP before planning again.`);
51
55
  if (!price.active) await api('POST', `/v1/prices/${price.id}`, { active: true });
52
56
  keepPrices.add(price.id);
53
57
  catalogPrices.set(item.id, price.id);
@@ -61,6 +65,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
61
65
  const linkRevision = hash({ params, item, policy: shop.policy });
62
66
  let link = links.find((p) => own(p) && p.metadata?.revision === linkRevision);
63
67
  if (!link) link = await api('POST', '/v1/payment_links', { ...params, metadata: { ...metadata, revision: linkRevision } }, `link-${linkRevision}`);
68
+ await verifyLink(link,params);
64
69
  if (!link.active) await api('POST', `/v1/payment_links/${link.id}`, { active: true });
65
70
  if (!paymentLinkURL(link.url) || !/^plink_\w+$/.test(link.id)) throw new Error('Invalid Payment Link');
66
71
  keepLinks.add(link.id);
@@ -73,7 +78,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
73
78
  const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
74
79
  const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
75
80
  const groups=Map.groupBy(menuItems,i=>i.game??'');
76
- for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
81
+ for(const group of (shop.basket ? [] : groups.values())) for (let offset = 0; offset < group.length; offset += 10) {
77
82
  const items = group.slice(offset, offset + 10);
78
83
  if (items.length < 2) continue;
79
84
  const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
@@ -83,13 +88,40 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
83
88
  const revision=hash(params);
84
89
  let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
85
90
  if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`menu-${revision}`);
91
+ await verifyLink(link,params);
86
92
  if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
87
93
  keepLinks.add(link.id);
88
94
  config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
89
95
  }
96
+ // One aggregate payment represents a frozen local basket, including one-item baskets.
97
+ // Runtime uses prefilled_amount, never a shared Price mutation: concurrent
98
+ // guests must receive their own saved totals.
99
+ if(shop.basket) for(const [game,items] of groups) {
100
+ const id=await productIdOf(slug,`basket-${game||'shop'}`);
101
+ keepProducts.add(id);
102
+ const fields={name:shop.basket.name,description:shop.basket.description,metadata,active:true};
103
+ const product=products.find(p=>p.id===id);
104
+ if(!product)await api('POST','/v1/products',{id,...fields},`product-${hash(id)}`);
105
+ else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
106
+ const priceFields={product:id,currency:shop.currency,custom_unit_amount:{enabled:true},tax_behavior:'inclusive'};
107
+ const priceRevision=hash(priceFields);
108
+ let price=prices.find(p=>own(p)&&p.product===id&&p.metadata?.revision===priceRevision);
109
+ if(!price)price=await api('POST','/v1/prices',{...priceFields,metadata:{...metadata,revision:priceRevision}},`price-${hash({priceFields,metadata})}`);
110
+ if(!price.active)await api('POST',`/v1/prices/${price.id}`,{active:true});
111
+ keepPrices.add(price.id);
112
+ const params={line_items:[{price:price.id,quantity:1}],automatic_tax:{enabled:false},payment_intent_data:{metadata},
113
+ after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
114
+ const revision=hash(params);
115
+ let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
116
+ if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`basket-${revision}`);
117
+ if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
118
+ if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
119
+ keepLinks.add(link.id);
120
+ config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
121
+ }
90
122
  config.slots=[];
91
123
  const pause=async link=>{
92
- await api('POST',`/v1/payment_links/${link.id}`,{active:false});
124
+ if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
93
125
  for(const session of await list('/v1/checkout/sessions',{payment_link:link.id,status:'open'})) {
94
126
  await api('POST',`/v1/checkout/sessions/${session.id}/expire`);
95
127
  }
@@ -107,11 +139,13 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
107
139
  let link=links.find(p=>own(p)&&p.metadata?.slot===identity);
108
140
  if(!link && (!slot.capacity||slot.active===false))continue;
109
141
  if(link?.metadata?.price && link.metadata.price!==catalogPrices.get(item.id))throw new Error('Slot service price changed. Deactivate the old slot and publish a new slot ID after reviewing existing bookings.');
110
- if(slot.confirmed!==undefined && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
142
+ const seatsRevision=hash({slot,capacity:slot.capacity,confirmed:slot.confirmed});
143
+ const counted=slot.confirmed!==undefined && link?.metadata?.seats_revision===seatsRevision;
144
+ if(slot.confirmed!==undefined && !counted && link?.active && slot.active!==false)throw new Error('Pause affected slot links before recounting confirmed seats; wait for signed payments, move/refund, then sync the fresh counts.');
111
145
  const count=link?.restrictions?.completed_sessions?.count??0;
112
146
  // Initial publication uses capacity exactly. After a reviewed move/refund the
113
147
  // AI supplies current confirmed seats; keep the existing Stripe lifetime count.
114
- const limit=slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
148
+ const limit=counted ? Number(link.metadata.seats_limit) : slot.confirmed===undefined ? slot.capacity : count+slot.capacity-slot.confirmed;
115
149
  const active=slot.active!==false && limit>count;
116
150
  const params={...customText,line_items:[{price:catalogPrices.get(item.id),quantity:1}],payment_method_types:['card'],
117
151
  restrictions:{completed_sessions:{limit:Math.max(1,limit)}},after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
@@ -119,9 +153,10 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
119
153
  else {
120
154
  // Pause first whenever publishing a changed budget, so old sessions cannot
121
155
  // spend a previous capacity after staff moves or removes a slot.
122
- if(!active || link.restrictions?.completed_sessions?.limit!==limit)await pause(link);
123
- link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active});
156
+ if(!active || link.restrictions?.completed_sessions?.limit!==Math.max(1,limit))await pause(link);
157
+ if (link.active !== active || link.restrictions?.completed_sessions?.limit !== Math.max(1,limit) || slot.confirmed!==undefined && !counted) link=await api('POST',`/v1/payment_links/${link.id}`,{restrictions:params.restrictions,active,...(slot.confirmed!==undefined?{metadata:{seats_revision:seatsRevision,seats_limit:String(limit)}}:{})});
124
158
  }
159
+ await verifyLink(link,params);
125
160
  keepLinks.add(link.id);
126
161
  config.slots.push({...slot,id:link.id,url:link.url,price:catalogPrices.get(item.id),revision:identity,active:link.active,count:link.restrictions?.completed_sessions?.count??0,limit,paused:slot.active===false});
127
162
  }
@@ -132,11 +167,40 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
132
167
  const portals=await list('/v1/billing_portal/configurations');
133
168
  let portal=portals.find(p=>own(p));
134
169
  const params={features:{subscription_cancel:{enabled:true},payment_method_update:{enabled:true},invoice_history:{enabled:true}},login_page:{enabled:true},business_profile:{headline:'Manage your membership or giving'},default_return_url:`${site}/shop/`,metadata};
135
- portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params);
170
+ if (!portal || !matchesFields(portal, params)) portal=await api('POST',portal?`/v1/billing_portal/configurations/${portal.id}`:'/v1/billing_portal/configurations',params, portal?undefined:`portal-${hash({slug,site,params})}`);
136
171
  config.portal=portal.login_page?.url;
137
172
  }
138
- if(local)return {ok:true,command:'shop connect',local:true,mode,config};
139
- const purchases = root ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
173
+ const retire = [
174
+ ...links.filter(p=>own(p)&&p.active&&!keepLinks.has(p.id)).map(p=>({method:'POST',path:`/v1/payment_links/${p.id}`,params:{active:false}})),
175
+ ...prices.filter(p=>own(p)&&p.active&&!keepPrices.has(p.id)).map(p=>({method:'POST',path:`/v1/prices/${p.id}`,params:{active:false}})),
176
+ ...products.filter(p=>own(p)&&p.active&&!keepProducts.has(p.id)).map(p=>({method:'POST',path:`/v1/products/${p.id}`,params:{active:false}})),
177
+ ];
178
+ return {config, retire};
179
+ }
180
+
181
+ export async function syncPaymentLinks(options) {
182
+ const {root, api, shop, slug, site, mode, w, receipt, saveReceipt, local = false} = options;
183
+ if (local && mode !== 'test') throw new Error('Local checkout requires Stripe test mode');
184
+ const {config, retire} = options.catalog ?? await reconcilePaymentLinks(options);
185
+ if (local) return {ok:true, command:'shop connect', local:true, mode, config};
186
+ const list = async (path,params={}) => {
187
+ const all=[]; let cursor;
188
+ do {
189
+ const page=await api('GET',path,{...params,limit:100,...(cursor?{starting_after:cursor}:{})});
190
+ if (!Array.isArray(page.data)) throw new Error('Incomplete Stripe list');
191
+ all.push(...page.data);
192
+ const next=page.has_more?page.data.at(-1)?.id:null;
193
+ if(page.has_more && (!next || next===cursor))throw new Error('Incomplete Stripe pagination');
194
+ cursor=next;
195
+ }while(cursor);
196
+ return all;
197
+ };
198
+ const secrets=await w(['secret','list']);
199
+ if(secrets.code!==0)return {ok:false,needs:'cloudflare',why:'Connect Cloudflare before installing the shop.'};
200
+ const names=JSON.parse(secrets.stdout??secrets.out);
201
+ const has=name=>names.some(s=>s.name===name);
202
+
203
+ const purchases = root && !options.catalog ? await (await import('./purchase-links.mjs')).syncPurchaseLinks({ root, api, slug, site, mode, list, till: shop.purchasesTill ?? shop.till }) : null;
140
204
  const purchaseSecrets = {};
141
205
  if (purchases?.config.offers.length && !has('PURCHASE_SIGNING_KEYS')) {
142
206
  const { generateKeyPair, exportJWK } = await import('jose');
@@ -166,9 +230,7 @@ export async function syncPaymentLinks({ root, api, shop, slug, site, mode, w, r
166
230
  }
167
231
  await purchases?.retire();
168
232
  // Retire only after the replacement configuration and signing secret are installed.
169
- 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 });
170
- for (const price of prices.filter((p) => own(p) && p.active && !keepPrices.has(p.id))) await api('POST', `/v1/prices/${price.id}`, { active: false });
171
- for (const product of products.filter((p) => own(p) && p.active && !keepProducts.has(p.id))) await api('POST', `/v1/products/${product.id}`, { active: false });
233
+ if (!options.catalog) for (const op of retire) await api(op.method, op.path, op.params);
172
234
  for (const e of endpoints.filter((e) => e.id !== endpoint.id && e.url === url && e.metadata?.homie === 'shop-v1' && e.status !== 'disabled')) await api('POST', `/v1/webhook_endpoints/${e.id}`, { disabled: true });
173
235
  return { ok: true, command: 'shop connect', mode, keyless: true, saved: !unchanged, alreadyConnected: Boolean(unchanged), webhook: { id: endpoint.id, url }, verifiedPurchase: false,
174
236
  message: 'Your shop uses Stripe Payment Links. No Stripe API key is stored in the Worker.',
@@ -0,0 +1,43 @@
1
+ /** Private local Stripe forwarding. Only the allowlisted readiness line reaches the model. */
2
+ import {spawn} from 'node:child_process';
3
+ import {readFileSync,writeFileSync,existsSync,chmodSync} from 'node:fs';
4
+ import {join} from 'node:path';
5
+ import {stripeProcess,completionArgs} from './stripe-connect.mjs';
6
+ export function saveLocalStripe(root,values) {
7
+ const file=join(root,'.dev.vars');
8
+ let text=existsSync(file)?readFileSync(file,'utf8'):'';
9
+ for(const [key,value] of Object.entries(values)) {
10
+ text=text.replace(new RegExp(`^${key}=.*\\n?`,'gm'),'');
11
+ text+=`\n${key}=${value}\n`;
12
+ }
13
+ writeFileSync(file,text,{mode:0o600});
14
+ chmodSync(file,0o600);
15
+ }
16
+ export async function shopListen(root,{profile,port=8787,log=()=>{},exec=stripeProcess,spawnProcess=spawn}={}) {
17
+ if(!profile || !Number.isInteger(Number(port)) || Number(port)<1 || Number(port)>65535)throw new Error('Supply a sandbox profile and local port');
18
+ const cli=args=>exec(['--project-name',profile,...args],{cwd:root});
19
+ const probe=await cli(['get','/v1/products','-d','limit=1']);
20
+ if(probe.code!==0) {
21
+ const start=await cli(['login','--non-interactive']);
22
+ let login;try{login=JSON.parse(start.stdout);}catch{}
23
+ const complete=completionArgs(login?.next_step);
24
+ const u=login?.browser_url;
25
+ if(!complete || !/^https:\/\/(dashboard|access)\.stripe\.com\//.test(u??''))throw new Error('Update Stripe CLI and retry local browser approval');
26
+ log(`Approve Stripe sandbox access: ${u}`);
27
+ if((await cli(complete)).code!==0)throw new Error('Stripe browser approval did not finish');
28
+ }
29
+ const env={...process.env};for(const key of Object.keys(env))if(key.startsWith('STRIPE_'))delete env[key];
30
+ return new Promise((resolve,reject)=>{
31
+ const child=spawnProcess('stripe',['--project-name',profile,'listen','--all-snapshot','--forward-to',`http://localhost:${port}/api/shop/hook`],{cwd:root,env,stdio:['ignore','pipe','pipe']});
32
+ let buffer='',ready=false;
33
+ const capture=data=>{
34
+ buffer=(buffer+data.toString()).slice(-8192);
35
+ const secret=/whsec_[A-Za-z0-9+/=_-]+(?=[\s\x1b])/.exec(buffer)?.[0];
36
+ if(secret && !ready){saveLocalStripe(root,{STRIPE_WEBHOOK_SECRET:secret});ready=true;buffer='';log('Stripe sandbox forwarding ready. Signing secret saved privately; start/restart homie-studio dev.');}
37
+ };
38
+ child.stdout.on('data',capture);child.stderr.on('data',capture);
39
+ child.on('error',()=>reject(new Error('Install Stripe CLI before local forwarding')));
40
+ const stop=()=>child.kill();process.once('SIGTERM',stop);process.once('SIGINT',stop);
41
+ child.on('close',code=>{process.removeListener('SIGTERM',stop);process.removeListener('SIGINT',stop);resolve({ok:ready&&code===0,command:'shop listen',ready});});
42
+ });
43
+ }
@@ -27,5 +27,5 @@ export async function shopConnectLocal(root,{url,profile,exec=stripeProcess}={})
27
27
  vars=vars.replace(/^STRIPE_(?:SHOP_LINKS|CHECKOUT_MODE)=.*\n?/gm,'');
28
28
  vars+=`\nSTRIPE_CHECKOUT_MODE=links\nSTRIPE_SHOP_LINKS=base64:${Buffer.from(JSON.stringify(r.config)).toString('base64')}\n`;
29
29
  writeFileSync(file,vars,{mode:0o600});
30
- return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`stripe --project-name ${profile} listen --all-snapshot --forward-to http://localhost:${origin.port||80}/api/shop/hook`,'Put the listener signing secret in root .dev.vars as STRIPE_WEBHOOK_SECRET, then start/restart homie-studio dev --lan. Keep the listener running.','Open the same Wi-Fi address on phones; complete Stripe test checkout and verify the paid order and app event.']};
30
+ return {ok:true,command:'shop connect',local:true,mode:'test',url:origin.origin,message:'Test Payment Links saved to root .dev.vars.',next:[`homie-studio shop listen --profile '${profile}' --port ${origin.port||80}`,'The listener saves the signing secret privately. Start/restart homie-studio dev --lan after it reports ready.','Open the same Wi-Fi address on phones and verify the paid order and app event.']};
31
31
  }