@homie-rocks/studio 0.58.21 → 0.58.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -17,6 +17,46 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.58.24] - 2026-10-29
21
+
22
+ **Plugin 0.58.15**
23
+
24
+ A studio Homie approved for test payments takes booking payments in Stripe TEST mode.
25
+
26
+ ### Added
27
+
28
+ - Booking checkout (`studio_checkout`, Venue desk `venue_booking_confirm`) opens a Stripe TEST Checkout page on Homie's sandbox account when the studio's `HOMIE_PAYMENTS` binding says test payments are open (the owner asks Talk to Homie, the Homie Team approves). Paying with a Stripe test card confirms the booking. Every other studio's booking checkout stays `closed`; no real money moves.
29
+
30
+ ## [0.58.23] - 2026-10-29
31
+
32
+ **Plugin 0.58.15**
33
+
34
+ A booking agent books, finds its hold, pays and cancels with one hold id and a private token, and every refusal comes with a stable error code.
35
+
36
+ ### Changed
37
+
38
+ - `studio_book` needs no `id`. The returned `data.id` (now `hold_` and 48 hex) is the hold: pass it as `hold` to the Venue desk tools, `studio_holds` and `studio_checkout`. An `id` you still send is an optional idempotency key, scoped to your account or private token, so two guests using the same slot name never collide (that was the `Booking owner required` error). Holds made by earlier versions keep their 64-hex ids and keep working.
39
+ - Every studio MCP tool refusal is an MCP error (`isError`) with `structuredContent` `{kind:'studio-error', error:{code, message, status}}` and a stable `code`, e.g. `hold_not_found`, `slot_full`, `hold_ended`, `invalid_input`, or a closed shop's `closed` with `details.missing`. `studio_checkout` no longer returns a closed shop as success.
40
+ - `studio_checkout` takes top-level `app`, `hold` and `token`, the same as the Venue desk tools, and fills in the booking's line (its service item × quantity). `venue_booking_confirm` (Venue desk 1.0.1), for a venue that takes payment in advance, returns `checkout: {tool:'studio_checkout', arguments, lines}` naming exactly what to call.
41
+
42
+ ### Added
43
+
44
+ - `studio_holds { token }` lists a guest's own active holds; `release` (a hold id, or `"all"`) releases them. Only that token's holds are ever shown or changed.
45
+
46
+ ### Fixed
47
+
48
+ - A hold past its expiry no longer takes a place: availability stops counting it, and the next booking on that slot releases it.
49
+
50
+ ## [0.58.22] - 2026-10-29
51
+
52
+ **Plugin 0.58.15**
53
+
54
+ The parts that come with the toolkit, like the Venue desk, add to a studio with no download.
55
+
56
+ ### Fixed
57
+
58
+ - `part_add` and `parts_find` find the parts that come with the toolkit (`parts/catalogue.json`: Venue desk, Owner action checklist, Terrain route map) in the installed toolkit, with no download: `part_add { "part": "venue-desk" }` (or `"homie.rocks/venue-desk"`) copies it in, every file checked against the catalogue's SHA-256, and `parts_find` lists them even when the shared catalogue cannot be reached.
59
+
20
60
  ## [0.58.21] - 2026-10-29
21
61
 
22
62
  **Plugin 0.58.15**
@@ -21,6 +21,7 @@ import { priceWords } from "../worker/purchase-pricing.mjs";
21
21
  import { spawnSync } from 'node:child_process';
22
22
  import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
23
23
  import { dirname, join } from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
24
25
  import {
25
26
  LIMITS, TUNING_FILE, TUNING_UPSTREAM, assetRecords, checkPart, compareVersions, isVersion, licenceIssues, licenseOfPart, listOwnParts, packPart, packedDir,
26
27
  packedVersions, parseRef, partDir, readOrigins, readPart, requiredParts, sha256, shareProblems, syncPartCredits, vendorDir, verifyFiles, writeHashes,
@@ -32,6 +33,45 @@ export const HUB = 'https://homie.rocks';
32
33
  const isObj = (v) => Boolean(v) && typeof v === 'object' && !Array.isArray(v);
33
34
  const line = (v, n = 200) => (typeof v === 'string' ? v.replace(/[\x00-\x1f\x7f]+/g, ' ').trim().slice(0, n) : '');
34
35
 
36
+ /* ------------------------------------------------------------------ the toolkit's own parts */
37
+
38
+ /**
39
+ * The parts that come WITH the toolkit (parts/catalogue.json in this package, e.g. venue-desk): found and added from the
40
+ * installed toolkit, with no network. "venue-desk" alone, or "homie.rocks/venue-desk", names one; every file is checked
41
+ * against the catalogue's SHA-256 and size exactly as a fetched part's are.
42
+ */
43
+ export const TOOLKIT_HOST = 'homie.rocks';
44
+ const TOOLKIT_PARTS = fileURLToPath(new URL('../parts/', import.meta.url));
45
+ export function bundledCatalogue() {
46
+ try {
47
+ const c = JSON.parse(readFileSync(join(TOOLKIT_PARTS, 'catalogue.json'), 'utf8'));
48
+ return Array.isArray(c?.parts) ? c.parts.filter((p) => isObj(p) && typeof p.id === 'string' && p.share === true && Array.isArray(p.files)) : [];
49
+ } catch { return []; }
50
+ }
51
+ /** A bundled part named by `refText`, read and checked; null when it is not one of the toolkit's. */
52
+ export function bundledPart(refText) {
53
+ const text = String(refText ?? '').trim();
54
+ const bare = /^([a-z0-9][a-z0-9-]{0,47})(?:@(.{1,60}))?$/.exec(text);
55
+ const ref = bare ? { host: TOOLKIT_HOST, id: bare[1], version: bare[2] ?? null, ref: `${TOOLKIT_HOST}/${bare[1]}` } : parseRef(text);
56
+ if (!ref || ref.host !== TOOLKIT_HOST) return null;
57
+ const part = bundledCatalogue().find((p) => p.id === ref.id);
58
+ if (!part) return null;
59
+ if (ref.version && ref.version !== part.version) throw new Error(`The toolkit has ${ref.id} ${part.version}, not ${ref.version}. Upgrade the toolkit for a newer one.`);
60
+ const shape = checkPart(part);
61
+ if (!shape.ok) throw new Error(`${ref.ref} ${part.version} is not a part this toolkit can read: ${shape.problems.filter((p) => p.level === 'refuse').map((p) => `${p.field}: ${p.problem}`).slice(0, 3).join('; ')}`);
62
+ const files = new Map();
63
+ for (const f of part.files) {
64
+ const at = join(TOOLKIT_PARTS, part.id, f.path);
65
+ if (!at.startsWith(join(TOOLKIT_PARTS, part.id) + '/')) throw new Error(`${ref.ref}: ${f.path} is outside the part`);
66
+ let bytes;
67
+ try { bytes = readFileSync(at); } catch { throw new Error(`${ref.ref}: ${f.path} is missing from the installed toolkit. Reinstall it (npm ci).`); }
68
+ if (bytes.length !== f.bytes || sha256(bytes) !== f.sha256) throw new Error(`${ref.ref} ${part.version}: ${f.path} in the installed toolkit is not the file its catalogue describes. Reinstall the toolkit (npm ci).`);
69
+ files.set(f.path, bytes);
70
+ }
71
+ const entry = { ...part, from: { ...(isObj(part.from) ? part.from : {}), studio: 'Homie toolkit' }, studio: { name: 'Homie toolkit' } };
72
+ return { ref: { ...ref, version: part.version }, part, files, entry, base: baseOf(TOOLKIT_HOST), version: part.version, versions: [part.version], access: null, bundled: true };
73
+ }
74
+
35
75
  /* ------------------------------------------------------------------ the one fetch */
36
76
 
37
77
  /** A studio's site from its host: https, except a site on this computer (the local preview), which has no certificate. */
@@ -196,7 +236,7 @@ export async function addPart(root, refText, { game = null, fetch, npm = runNpm,
196
236
  if (game && !existsSync(experienceFile(root, game))) return fail(`there is no game "${game}" in this studio (${listGames(root).map((g) => g.id).join(', ') || 'it has none yet'})`);
197
237
  let got;
198
238
  try {
199
- got = offline ? await offlinePurchase(root, refText, game) : await fetchPart(refText, { fetch, onPaid: (ref, part, offer) => paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay }) });
239
+ got = offline ? await offlinePurchase(root, refText, game) : bundledPart(refText) ?? await fetchPart(refText, { fetch, onPaid: (ref, part, offer) => paidAccess(root, ref, part, { game, quantity, approve, fetch, offer, wallet, walletPay }) });
200
240
  if (got.access) {
201
241
  savePurchaseBackup(root, got, got.access, game);
202
242
  const c = got.access.claims;
@@ -345,6 +385,10 @@ export async function findParts(root, query = '', { kind = null, tag = null, lic
345
385
  if (part) here.push(row({ ...part, from: e.from ?? part.from }, { where: 'here', ref, add: ref, import: part.entry ? `@parts/${ref}` : null, games: e.games ?? [], say: `Already in this studio (${e.version}): import it from '@parts/${ref}'.` }));
346
386
  }
347
387
  }
388
+ for (const p of bundledCatalogue()) {
389
+ const ref = `${TOOLKIT_HOST}/${p.id}`;
390
+ if (!here.some((h) => h.ref === ref)) here.push(row({ ...p, from: { ...(isObj(p.from) ? p.from : {}), studio: 'Homie toolkit' } }, { where: 'toolkit', ref, add: ref, import: null, say: `Comes with the Homie toolkit (no download): part_add { "part": "${ref}" }.` }));
391
+ }
348
392
  let base = hub;
349
393
  if (!base && root) { try { const d = readStudio(root).homie?.directory; base = d === false || d === null ? null : d; } catch { base = null; } }
350
394
  base = /^https?:\/\/[a-z0-9.:-]+$/i.test(String(base ?? '')) ? base : HUB;
@@ -353,7 +397,7 @@ export async function findParts(root, query = '', { kind = null, tag = null, lic
353
397
  try {
354
398
  const index = await getJson(url, { fetch, what: 'the catalogue of shared parts' });
355
399
  if (!Array.isArray(index?.parts)) throw new Error(`${url} is not a parts catalogue`);
356
- const have = new Set(here.filter((h) => h.where === 'here').map((h) => h.ref));
400
+ const have = new Set(here.filter((h) => h.where === 'here' || h.where === 'toolkit').map((h) => h.ref));
357
401
  found = index.parts.filter((p) => isObj(p) && typeof p.id === 'string' && (typeof p.host === 'string' || typeof p.add === 'string')).map((p) => {
358
402
  const ref = parseRef(typeof p.add === 'string' ? p.add : `${p.host}/${p.id}`)?.ref;
359
403
  return ref ? row(p, { where: 'hub', ref, add: ref, studio: line(p.studio?.name, 80) || null, page: /^https:\/\//.test(String(p.page ?? '')) ? p.page : null, here: have.has(ref), say: `${p.sale ? `Paid: ${priceWords(p.sale)}. Ask for approval of the exact quote before requesting payment. ` : 'Free. '}Add it: part_add { "part": "${ref}" }.` }) : null;
@@ -4866,6 +4866,132 @@
4866
4866
  }
4867
4867
  }
4868
4868
  }
4869
+ },
4870
+ "0.58.22": {
4871
+ "files": {
4872
+ "CLAUDE.md": "ddf2b2a0376202f6",
4873
+ "README.md": "684b3cca8001673c",
4874
+ "HANDOFF.md": "29337e2ace95b2e7",
4875
+ "games/README.md": "5ad32fe80c61f117",
4876
+ "music/README.md": "a3dc042dadd39df8",
4877
+ "videos/README.md": "16babc3437d5545b",
4878
+ "posts/README.md": "37841aae476bd540",
4879
+ "site/README.md": "1dbdf22c03a1e505",
4880
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4881
+ },
4882
+ "sections": {
4883
+ "AGENTS.md": {
4884
+ "(intro)": "af7f2f732505d379",
4885
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4886
+ "## Layout": "f3a280b8c96cd068",
4887
+ "## Apps: one screen, roles and parts": "2ecf713305fd26f5",
4888
+ "## Commands (all through the pinned CLI in node_modules)": "661cbdf5d6a9dbae",
4889
+ "## The site": "0cb8dd9a6f8a0096",
4890
+ "## Making games": "644c1e7608bb9740",
4891
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4892
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4893
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4894
+ "## Servers and AI seats": "1904f45b599a33e4",
4895
+ "## Selling things (the shop)": "e5bd380cb77f2b11",
4896
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4897
+ "## Rules": "b2234d80f6cbe71d",
4898
+ "## Telling Homie": "80b46fdd9e578db8",
4899
+ "## Beta": "9d02638207a26e73"
4900
+ }
4901
+ },
4902
+ "named": {
4903
+ "Homie Arcade": {
4904
+ "sections": {
4905
+ "AGENTS.md": {
4906
+ "## Commands (all through the pinned CLI in node_modules)": "0a7b2c36414a29a0"
4907
+ }
4908
+ }
4909
+ }
4910
+ }
4911
+ },
4912
+ "0.58.23": {
4913
+ "files": {
4914
+ "CLAUDE.md": "ddf2b2a0376202f6",
4915
+ "README.md": "684b3cca8001673c",
4916
+ "HANDOFF.md": "29337e2ace95b2e7",
4917
+ "games/README.md": "5ad32fe80c61f117",
4918
+ "music/README.md": "a3dc042dadd39df8",
4919
+ "videos/README.md": "16babc3437d5545b",
4920
+ "posts/README.md": "37841aae476bd540",
4921
+ "site/README.md": "1dbdf22c03a1e505",
4922
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4923
+ },
4924
+ "sections": {
4925
+ "AGENTS.md": {
4926
+ "(intro)": "af7f2f732505d379",
4927
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4928
+ "## Layout": "f3a280b8c96cd068",
4929
+ "## Apps: one screen, roles and parts": "2ecf713305fd26f5",
4930
+ "## Commands (all through the pinned CLI in node_modules)": "661cbdf5d6a9dbae",
4931
+ "## The site": "0cb8dd9a6f8a0096",
4932
+ "## Making games": "644c1e7608bb9740",
4933
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4934
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4935
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4936
+ "## Servers and AI seats": "1904f45b599a33e4",
4937
+ "## Selling things (the shop)": "e5bd380cb77f2b11",
4938
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4939
+ "## Rules": "b2234d80f6cbe71d",
4940
+ "## Telling Homie": "80b46fdd9e578db8",
4941
+ "## Beta": "9d02638207a26e73"
4942
+ }
4943
+ },
4944
+ "named": {
4945
+ "Homie Arcade": {
4946
+ "sections": {
4947
+ "AGENTS.md": {
4948
+ "## Commands (all through the pinned CLI in node_modules)": "0a7b2c36414a29a0"
4949
+ }
4950
+ }
4951
+ }
4952
+ }
4953
+ },
4954
+ "0.58.24": {
4955
+ "files": {
4956
+ "CLAUDE.md": "ddf2b2a0376202f6",
4957
+ "README.md": "684b3cca8001673c",
4958
+ "HANDOFF.md": "29337e2ace95b2e7",
4959
+ "games/README.md": "5ad32fe80c61f117",
4960
+ "music/README.md": "a3dc042dadd39df8",
4961
+ "videos/README.md": "16babc3437d5545b",
4962
+ "posts/README.md": "37841aae476bd540",
4963
+ "site/README.md": "1dbdf22c03a1e505",
4964
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
4965
+ },
4966
+ "sections": {
4967
+ "AGENTS.md": {
4968
+ "(intro)": "af7f2f732505d379",
4969
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
4970
+ "## Layout": "f3a280b8c96cd068",
4971
+ "## Apps: one screen, roles and parts": "2ecf713305fd26f5",
4972
+ "## Commands (all through the pinned CLI in node_modules)": "661cbdf5d6a9dbae",
4973
+ "## The site": "0cb8dd9a6f8a0096",
4974
+ "## Making games": "644c1e7608bb9740",
4975
+ "## The Game Codex and progress": "0738ef719cef3a4c",
4976
+ "## Art direction and models": "a48fff7bf0a5f2fc",
4977
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
4978
+ "## Servers and AI seats": "1904f45b599a33e4",
4979
+ "## Selling things (the shop)": "e5bd380cb77f2b11",
4980
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
4981
+ "## Rules": "b2234d80f6cbe71d",
4982
+ "## Telling Homie": "80b46fdd9e578db8",
4983
+ "## Beta": "9d02638207a26e73"
4984
+ }
4985
+ },
4986
+ "named": {
4987
+ "Homie Arcade": {
4988
+ "sections": {
4989
+ "AGENTS.md": {
4990
+ "## Commands (all through the pinned CLI in node_modules)": "0a7b2c36414a29a0"
4991
+ }
4992
+ }
4993
+ }
4994
+ }
4869
4995
  }
4870
4996
  }
4871
4997
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.58.21",
3
+ "version": "0.58.24",
4
4
  "description": "Open-source tools that let your AI build and run a multiplayer game studio on its own Cloudflare: an MCP server, multiplayer starters, Workers and Durable Objects.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -236,7 +236,7 @@
236
236
  "id": "venue-desk",
237
237
  "name": "Venue desk",
238
238
  "kind": "tools",
239
- "version": "1.0.0",
239
+ "version": "1.0.1",
240
240
  "summary": "Public venue info (hours, closures, policies, FAQ), booking contact and confirmation on top of studio holds, and a waitlist with an owner outbox. Any venue, schema.org outputs.",
241
241
  "license": "Apache-2.0",
242
242
  "attribution": "Ryan Campbell — Homie toolkit",
@@ -307,8 +307,8 @@
307
307
  },
308
308
  {
309
309
  "path": "src/index.ts",
310
- "sha256": "063876b14067c97c5c5dec4dd9f04dcfa60cfee7ea72e29a347412b5eed5c3b8",
311
- "bytes": 19530
310
+ "sha256": "16622b3ce82ffd38cf1ba9288fd6161da15aba36b63ac43ce55ef6acdd414868",
311
+ "bytes": 19883
312
312
  },
313
313
  {
314
314
  "path": "tools/venue-booking-cancel.ts",
@@ -367,7 +367,7 @@
367
367
  }
368
368
  ],
369
369
  "cost": {
370
- "bytes": 36701
370
+ "bytes": 37054
371
371
  }
372
372
  }
373
373
  ]
@@ -2,7 +2,7 @@
2
2
  "id": "venue-desk",
3
3
  "name": "Venue desk",
4
4
  "kind": "tools",
5
- "version": "1.0.0",
5
+ "version": "1.0.1",
6
6
  "summary": "Public venue info (hours, closures, policies, FAQ), booking contact and confirmation on top of studio holds, and a waitlist with an owner outbox. Any venue, schema.org outputs.",
7
7
  "license": "Apache-2.0",
8
8
  "attribution": "Ryan Campbell — Homie toolkit",
@@ -73,8 +73,8 @@
73
73
  },
74
74
  {
75
75
  "path": "src/index.ts",
76
- "sha256": "063876b14067c97c5c5dec4dd9f04dcfa60cfee7ea72e29a347412b5eed5c3b8",
77
- "bytes": 19530
76
+ "sha256": "16622b3ce82ffd38cf1ba9288fd6161da15aba36b63ac43ce55ef6acdd414868",
77
+ "bytes": 19883
78
78
  },
79
79
  {
80
80
  "path": "tools/venue-booking-cancel.ts",
@@ -133,6 +133,6 @@
133
133
  }
134
134
  ],
135
135
  "cost": {
136
- "bytes": 36701
136
+ "bytes": 37054
137
137
  }
138
138
  }
@@ -17,7 +17,7 @@ const NS = 'venue-desk';
17
17
  const TOKEN = {type: 'string', pattern: '^[a-f0-9]{32,128}$', description: 'The private random hex token the caller chose for studio_book (or for this waitlist entry). Keep it; it is the only proof the booking is yours.'};
18
18
  const ID = {type: 'string', pattern: '^[A-Za-z0-9_-]{1,64}$'};
19
19
  const short = (n) => ({type: 'string', maxLength: n});
20
- const fail = (message) => Object.assign(new Error(message), {name: 'StudioOperationError'});
20
+ const fail = (message, code = 'invalid_request', status = 400) => Object.assign(new Error(message), {name: 'StudioOperationError', code, status});
21
21
  const now = () => Date.now();
22
22
  const iso = (ms) => (Number.isFinite(ms) ? new Date(ms).toISOString() : null);
23
23
  const clean = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined && v !== null && v !== ''));
@@ -90,7 +90,7 @@ const CONTACT = {
90
90
  partySize: {type: 'integer', minimum: 1, maximum: 1000}, notes: {...short(1000), description: 'Allergies, accessibility, occasion: the guest\'s words, shown to staff.'},
91
91
  };
92
92
  async function ownHold(ctx, hold, token) {
93
- try { return await ctx.bookings.get(clean({id: hold, token})); } catch { throw fail('No such booking for this token'); }
93
+ try { return await ctx.bookings.get(clean({id: hold, token})); } catch { throw fail('No hold with this id for this token (pass data.id from studio_book and the same token)', 'hold_not_found', 403); }
94
94
  }
95
95
  function reservation(h, c, extra = {}) {
96
96
  const status = {held: 'ReservationHold', confirmed: 'ReservationConfirmed', released: 'ReservationCancelled', pending: 'ReservationPending'}[h.state] ?? 'ReservationPending';
@@ -100,11 +100,11 @@ function reservation(h, c, extra = {}) {
100
100
  export const venueBookingDetails = {
101
101
  name: 'venue_booking_details', namespace: NS, audience: 'public',
102
102
  description: 'After studio_book: attach the guest\'s contact to the hold (name, email and/or phone, party size, notes). Staff see it; it is never shown publicly. Call again to correct it. Then venue_booking_confirm, or studio_checkout when the venue takes payment in advance.',
103
- inputSchema: {type: 'object', properties: {hold: {...ID, description: 'The id given to studio_book.'}, token: TOKEN, ...CONTACT}, required: ['hold', 'name'], additionalProperties: false},
103
+ inputSchema: {type: 'object', properties: {hold: {...ID, description: 'The hold: data.id returned by studio_book (hold_…).'}, token: TOKEN, ...CONTACT}, required: ['hold', 'name'], additionalProperties: false},
104
104
  async handler({hold, token, ...c}, ctx) {
105
105
  if (!c.email && !c.phone) throw fail('Give an email or a phone so the venue can reach the guest');
106
106
  const h = await ownHold(ctx, hold, token);
107
- if (h.state === 'released') throw fail('This hold has ended; book again');
107
+ if (h.state === 'released') throw fail('This hold has ended; book again', 'hold_ended', 409);
108
108
  const prev = await ctx.database.get('booking:' + hold);
109
109
  await ctx.database.put('booking:' + hold, {...(prev ?? {}), hold, slot: h.slot, quantity: h.quantity, contact: clean(c), tokenHash: token ? await hash(token) : null, state: h.state, at: prev?.at ?? now(), updatedAt: now()});
110
110
  return {ok: true, next: (await info(ctx)).booking?.paymentInAdvance ? 'studio_checkout' : 'venue_booking_confirm', reservation: reservation(h, c)};
@@ -121,8 +121,8 @@ export const venueBookingConfirm = {
121
121
  if (!rec?.contact) throw fail('Add the guest\'s contact with venue_booking_details first');
122
122
  let h = await ownHold(ctx, hold, token);
123
123
  if (h.state === 'confirmed') return {ok: true, already: true, reservation: reservation(h, rec.contact)};
124
- if (h.state !== 'held') throw fail('This hold has ended; book again');
125
- if (v.booking?.paymentInAdvance) return {ok: false, next: 'studio_checkout', why: 'This venue confirms bookings on payment.', checkoutBooking: {collection: v.booking.collection, slot: h.slot, hold}};
124
+ if (h.state !== 'held') throw fail('This hold has ended; book again', 'hold_ended', 409);
125
+ if (v.booking?.paymentInAdvance) return {ok: false, next: 'studio_checkout', why: 'This venue confirms bookings on payment.', checkoutBooking: {collection: v.booking.collection, slot: h.slot, hold}, checkout: clean({tool: 'studio_checkout', arguments: clean({app: h.app, hold, token}), lines: h.item ? [{item: h.item, quantity: h.quantity}] : undefined})};
126
126
  h = await ctx.bookings.confirm(clean({id: hold, token, version: h.version}));
127
127
  await ctx.database.put('booking:' + hold, {...rec, state: 'confirmed', confirmedAt: now()});
128
128
  await log(ctx, {at: now(), what: 'booking.confirmed', hold, slot: h.slot, quantity: h.quantity, name: rec.contact.name});
@@ -13,7 +13,7 @@ export async function bookingAvailability(env,cat,caller,{app,collection,after='
13
13
  if(!readable)throw fail('Booking availability unavailable');
14
14
  }
15
15
  const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
16
- const rows=(await env.DB.prepare(`SELECT s.id,s.value,COALESCE((SELECT SUM(b.quantity) FROM shop_bookings b WHERE b.app=s.app AND b.slots=s.collection AND b.slot=s.id AND b.state IN ('held','confirmed')),0) used FROM app_records s WHERE s.app=?1 AND s.collection=?2 AND s.id>?3 ORDER BY s.id LIMIT 51`).bind(scope,spec.slots,after).all()).results;
16
+ const rows=(await env.DB.prepare(`SELECT s.id,s.value,COALESCE((SELECT SUM(b.quantity) FROM shop_bookings b WHERE b.app=s.app AND b.slots=s.collection AND b.slot=s.id AND (b.state='confirmed' OR b.state='held' AND NOT (b.order_id='hold:'||b.id AND b.hold_until<=?4))),0) used FROM app_records s WHERE s.app=?1 AND s.collection=?2 AND s.id>?3 ORDER BY s.id LIMIT 51`).bind(scope,spec.slots,after,Date.now()).all()).results;
17
17
  const snapshot=await resourceSnapshot(env,scope,spec);
18
18
  const items=[];
19
19
  for(const row of rows.slice(0,50)){
@@ -3,6 +3,7 @@ import {loadAndAllocateResources} from './booking-resources.mjs';
3
3
  import {normalizeBookingArgs} from './booking-options.mjs';
4
4
  import {BOOKINGS_MIGRATION} from './booking-store.mjs';
5
5
  import {linkConfig,hasStripeKey} from './shop-links.mjs';
6
+ import {syncProviderOrder} from './booking-provider.mjs';
6
7
  export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
7
8
  export const BOOKING_HOLDS_MIGRATION=`
8
9
  ALTER TABLE shop_bookings ADD COLUMN paid_at INTEGER;
@@ -35,54 +36,90 @@ ${['insert','update'].map(op=>{
35
36
  }).join('\n')}
36
37
  UPDATE shop_bookings SET paid_at=(SELECT paid_at FROM shop_orders WHERE id=order_id),version=version+1 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=order_id AND paid_at IS NOT NULL);
37
38
  `;
38
- const refusal=(message,status=400)=>Object.assign(new Error(message),{name:'StudioOperationError',status});
39
+ const CODES={400:'invalid_request',403:'forbidden',404:'not_found',409:'conflict'};
40
+ const refusal=(message,status=400,code=CODES[status]??'invalid_request')=>Object.assign(new Error(message),{name:'StudioOperationError',status,code});
39
41
  const valid=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(v);
42
+ /** A hold id: hold_ and 48 hex (from 0.58.23), or the 64-hex id earlier releases returned. Never shaped like a guest token. */
43
+ export const HOLD_ID=/^(hold_[a-f0-9]{48}|[a-f0-9]{64})$/;
44
+ const holdId=async parts=>'hold_'+(await hash(JSON.stringify(parts))).slice(0,48);
45
+ const randomHex=n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
40
46
  const hash=async token=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(token))),b=>b.toString(16).padStart(2,'0')).join('');
41
47
  export async function ownedHold(env,caller,{id,token}){
42
- if(!valid(id))throw refusal('Invalid booking id');
48
+ if(!valid(id))throw refusal('Invalid hold id: pass data.id from studio_book',400,'invalid_hold');
43
49
  const row=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(id).first();
44
- if(!row||!(caller.owner||row.actor&&row.actor===caller.id||row.token_hash&&typeof token==='string'&&await hash(token)===row.token_hash))throw refusal('Booking owner required',403);
45
- if(caller.testScope&&!row.app.endsWith(':check:'+caller.testScope))throw refusal('Booking owner required',403);
50
+ // One answer for "no such hold" and "not yours", so a hold id reveals nothing to someone without its token.
51
+ if(!row||!(caller.owner||row.actor&&row.actor===caller.id||row.token_hash&&typeof token==='string'&&await hash(token)===row.token_hash))throw refusal('No hold with this id for this token (pass data.id from studio_book and the same token)',403,'hold_not_found');
52
+ if(caller.testScope&&!row.app.endsWith(':check:'+caller.testScope))throw refusal('No hold with this id for this token (pass data.id from studio_book and the same token)',403,'hold_not_found');
46
53
  return row;
47
54
  }
48
55
  /** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
49
56
  export function bookingHolds(env,caller,cat){
50
- const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null,...(r.allocation&&r.allocation!=='[]'?{allocations:JSON.parse(r.allocation)}:{}),...(r.choices&&r.choices!=='{}'?{choices:JSON.parse(r.choices)}:{})});
57
+ const spec=r=>cat?.games?.find(g=>g.kind==='app'&&g.id===String(r.app).split(':check:')[0])?.records?.collections?.[r.collection]?.booking;
58
+ const result=r=>({id:r.id,hold:r.id,app:String(r.app).split(':check:')[0],collection:r.collection,...(spec(r)?.item?{item:spec(r).item}:{}),slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null,...(r.allocation&&r.allocation!=='[]'?{allocations:JSON.parse(r.allocation)}:{}),...(r.choices&&r.choices!=='{}'?{choices:JSON.parse(r.choices)}:{})});
51
59
  const change=async(args,state)=>{
52
60
  const row=await ownedHold(env,caller,args);
53
- if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release');
54
- if(!Number.isSafeInteger(args.version)||args.version<1)throw refusal('Booking version required');
61
+ if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release',409,'checkout_owns_hold');
62
+ if(!Number.isSafeInteger(args.version)||args.version<1)throw refusal('Booking version required',400,'version_required');
55
63
  const changed=await env.DB.prepare(`UPDATE shop_bookings SET state=?2,version=version+1,updated_at=?3 WHERE id=?1 AND version=?4 AND state IN ('held','confirmed') AND (?2='released' OR hold_until>?3) RETURNING *`).bind(row.id,state,Date.now(),args.version).first();
56
- if(!changed)throw refusal('Booking changed or expired; reload',409);return result(changed);
64
+ if(!changed)throw refusal('Booking changed or expired; reload',409,'hold_changed_or_expired');return result(changed);
57
65
  };
58
- return Object.freeze({
66
+ const holds=Object.freeze({
59
67
  calendar({app,...options}){const meta=cat.games.find(g=>g.id===app&&g.kind==='app');if(!meta)throw refusal('Unknown app');return calendarSlots({timeZone:meta.timeZone,...options});},
60
68
  async hold(input){
61
69
  const args=normalizeBookingArgs(input);
62
70
  let {app,collection,slot,quantity=1,id,token,expiresAt}=args;
63
71
  const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
64
- if(!spec||!valid(id)||!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1)throw refusal('Choose a declared slot, positive quantity and stable booking id');
65
- if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry');
66
- if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token');
67
- if(!caller.id&&!token)throw refusal('Booking owner or private token required');
72
+ if(!spec)throw refusal('Unknown booking collection',400,'unknown_collection');
73
+ if(!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1||id!==undefined&&!valid(id))throw refusal('Choose a declared slot and a positive quantity',400,'invalid_booking');
74
+ if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry',400,'invalid_expiry');
75
+ if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token',400,'invalid_token');
76
+ if(!caller.id&&!token)throw refusal('Booking owner or private token required',400,'token_required');
68
77
  if(!hasStripeKey(env)&&linkConfig(env)?.slots?.some(l=>l.app===app&&l.slots===spec.slots&&l.slot===slot))throw refusal('Keyless Stripe inventory cannot share local holds; configure session checkout first');
69
78
  const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
70
- id=await hash(JSON.stringify([scope,collection,id]));
79
+ const secret=token?await hash(token):null;
71
80
  const choices={option:args.option??null,resource:args.resource??null,addons:args.addons??{}};
72
- if(await env.DB.prepare('SELECT id FROM shop_bookings WHERE id=?1').bind(id).first()) {
73
- const previous=await ownedHold(env,caller,{id,token});
74
- if(previous.app!==scope||previous.collection!==collection||previous.slot!==slot||previous.quantity!==quantity||JSON.stringify({...choices,...JSON.parse(previous.choices??'{}')})!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
75
- return result(previous);
81
+ // `id` (optional, deprecated name for an idempotency key): a retry with the same key, token and request returns the
82
+ // same hold. The key is scoped to its owner (account or token), so two guests' keys never collide. The hold's own
83
+ // id is always the returned data.id (hold_…), never the key.
84
+ const same=previous=>previous.app===scope&&previous.collection===collection&&previous.slot===slot&&previous.quantity===quantity&&JSON.stringify({...choices,...JSON.parse(previous.choices??'{}')})===JSON.stringify(choices);
85
+ if(id!==undefined){
86
+ const legacy=await hash(JSON.stringify([scope,collection,id]));
87
+ for(const candidate of [await holdId([scope,collection,id,caller.id??secret]),legacy]){
88
+ const existing=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(candidate).first();
89
+ if(!existing)continue;
90
+ let previous;try{previous=await ownedHold(env,caller,{id:candidate,token});}catch{if(candidate===legacy)continue;throw refusal('Idempotency key reused',409,'key_conflict');}
91
+ if(previous.state==='released'||previous.state==='held'&&previous.hold_until<=Date.now()){if(candidate===legacy)continue;throw refusal('That key\'s hold has ended; book again without it or with a new one',409,'hold_ended');}
92
+ if(!same(previous))throw refusal('Idempotency key reused with a different request',409,'key_conflict');
93
+ return result(previous);
94
+ }
76
95
  }
77
- let allocation;try{allocation=await loadAndAllocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
78
- const secret=token?await hash(token):null;
96
+ id=id!==undefined?await holdId([scope,collection,id,caller.id??secret]):'hold_'+randomHex(24);
97
+ // An abandoned hold past its expiry stops taking a place as soon as anyone books that slot.
98
+ await env.DB.prepare("UPDATE shop_bookings SET state='released',version=version+1,updated_at=?4 WHERE app=?1 AND slots=?2 AND slot=?3 AND state='held' AND order_id='hold:'||id AND hold_until<=?4").bind(scope,spec.slots,slot,Date.now()).run();
99
+ let allocation;try{allocation=await loadAndAllocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409,'resource_unavailable');}
79
100
  try {await env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,state,updated_at,actor,token_hash,hold_until,allocation,choices)
80
- SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10,?11,?12 WHERE NOT EXISTS(SELECT 1 FROM shop_bookings WHERE id=?1)`).bind(id,scope,collection,spec.slots,slot,quantity,Date.now(),caller.id??null,secret,expiresAt,JSON.stringify(allocation),JSON.stringify(choices)).run();}catch(error){if(/booking capacity unavailable|resource capacity unavailable/.test(error.message))throw refusal('That slot or resource capacity is unavailable. No place has been taken.',409);throw error;}
101
+ SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10,?11,?12 WHERE NOT EXISTS(SELECT 1 FROM shop_bookings WHERE id=?1)`).bind(id,scope,collection,spec.slots,slot,quantity,Date.now(),caller.id??null,secret,expiresAt,JSON.stringify(allocation),JSON.stringify(choices)).run();}catch(error){if(/booking capacity unavailable|resource capacity unavailable/.test(error.message))throw refusal('That slot or resource capacity is unavailable. No place has been taken.',409,'slot_full');throw error;}
81
102
  const row=await ownedHold(env,caller,{id,token});
82
- if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity||JSON.stringify(JSON.parse(row.choices??'{}'))!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
103
+ if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity||JSON.stringify(JSON.parse(row.choices??'{}'))!==JSON.stringify(choices))throw refusal('Idempotency key reused with a different request',409,'key_conflict');
104
+ return result(row);
105
+ },
106
+ /** The caller's own holds: by its account, or by the private token (that token's holds only). Newest first. */
107
+ async mine({token,app,active=true}={}){
108
+ if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token',400,'invalid_token');
109
+ if(!token&&!caller.id)throw refusal('Pass the private token you gave studio_book',400,'token_required');
110
+ const rows=(await env.DB.prepare(`SELECT * FROM shop_bookings WHERE (${token?'token_hash=?1':'actor=?1'}) AND (?2 IS NULL OR app=?2 OR app LIKE ?2||':check:%') AND (?3=0 OR (state='confirmed' OR state='held' AND hold_until>?4)) ORDER BY updated_at DESC LIMIT 50`).bind(token?await hash(token):caller.id,app??null,active?1:0,Date.now()).all()).results??[];
111
+ const own=rows.filter(r=>!caller.testScope||r.app.endsWith(':check:'+caller.testScope));
112
+ let synced=false;
113
+ for(const r of own.filter(r=>r.state==='held'&&typeof r.order_id==='string'&&r.order_id.startsWith('ord_')).slice(0,3))synced=await syncProviderOrder(env,cat,r.order_id)||synced;
114
+ if(synced)return holds.mine({token,app,active});
115
+ return own.map(result);
116
+ },
117
+ async get(args){
118
+ let row=await ownedHold(env,caller,args);
119
+ // A booking paid through the host's checkout provider is recorded when its owner next looks (booking-provider.mjs).
120
+ if(row.state==='held'&&typeof row.order_id==='string'&&row.order_id.startsWith('ord_')&&await syncProviderOrder(env,cat,row.order_id))row=await ownedHold(env,caller,args);
83
121
  return result(row);
84
122
  },
85
- async get(args){return result(await ownedHold(env,caller,args));},
86
123
  confirm:args=>change(args,'confirmed'),release:args=>change(args,'released'),
87
124
  async releaseExpired({app,collection,now=Date.now()}){
88
125
  if(!caller.owner)throw refusal('Owner or scheduled function required');
@@ -91,6 +128,7 @@ export function bookingHolds(env,caller,cat){
91
128
  const r=await env.DB.prepare("UPDATE shop_bookings SET state='released',version=version+1,updated_at=?3 WHERE app=?1 AND collection=?2 AND order_id='hold:'||id AND state='held' AND hold_until<=?3").bind(scope,collection,now).run();return {released:r.meta.changes};
92
129
  }
93
130
  });
131
+ return holds;
94
132
  }
95
133
  /** Used in the same transaction as the order reservation. A second checkout cannot claim the hold. */
96
134
  export function claimHoldStatement(env,{hold,app,collection,slots,slot,quantity,order,now=Date.now()}){
@@ -20,4 +20,4 @@ export function normalizeBookingArgs(input) {
20
20
 
21
21
  const text={type:'string',maxLength:64};
22
22
  const addons={oneOf:[{type:'array',maxItems:100,items:text},{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}]};
23
- export const bookingInputSchema={type:'object',properties:{app:text,collection:text,slot:text,time:text,option:text,resource:text,preferredResource:text,preferred_resource:text,addons,addOns:addons,'add-ons':addons,quantity:{type:'integer',minimum:1},id:text,token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},expiresAt:{type:'integer'}},required:['app','collection','id','expiresAt'],additionalProperties:false};
23
+ export const bookingInputSchema={type:'object',properties:{app:text,collection:text,slot:text,time:text,option:text,resource:text,preferredResource:text,preferred_resource:text,addons,addOns:addons,'add-ons':addons,quantity:{type:'integer',minimum:1},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'Optional idempotency key (deprecated name): a retry with the same key and token returns the same hold. It is not the hold id; use data.id.'},token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},expiresAt:{type:'integer'}},required:['app','collection','expiresAt'],additionalProperties:false};
@@ -0,0 +1,54 @@
1
+ import { hasStripeKey } from './shop-links.mjs';
2
+ /**
3
+ * A HOSTED BOOKING CHECKOUT PROVIDER (0.58.24). A host may bind HOMIE_PAYMENTS, a service that opens Stripe Checkout
4
+ * for a studio's bookings on the host's own Stripe account (Homie's managed hosting does, in Stripe test mode, for
5
+ * studios its operator approved). With no such binding, or with the studio's own STRIPE_KEY, nothing here changes
6
+ * anything: the studio's own Stripe and Payment Links paths run as before. Every call fails closed: a missing method,
7
+ * a thrown call or any answer but `true` means the provider is not open.
8
+ *
9
+ * Provider contract (all plain JSON):
10
+ * bookingCheckoutOpen() -> true when this studio may open a booking checkout now
11
+ * bookingCheckout(params) -> { id: 'cs_…', url: 'https://…', expires_at, livemode } from the toolkit's own
12
+ * Checkout Session params (sessionParams)
13
+ * bookingSession(id) -> the Checkout Session (Stripe's shape, payment_intent expanded) or null
14
+ */
15
+ const META = (order) => `booking-provider:${order}`;
16
+
17
+ export async function providerOpen(env) {
18
+ if (!env?.HOMIE_PAYMENTS || hasStripeKey(env)) return false;
19
+ try { return (await env.HOMIE_PAYMENTS.bookingCheckoutOpen()) === true; } catch { return false; }
20
+ }
21
+
22
+ /** Opens the session through the provider and remembers that this order belongs to it. */
23
+ export async function providerCheckout(env, orderId, params) {
24
+ const session = await env.HOMIE_PAYMENTS.bookingCheckout(JSON.parse(JSON.stringify(params)));
25
+ if (session?.state === 'rejected') throw Object.assign(new Error('The checkout is not open for this studio'), { code: 'closed' });
26
+ if (typeof session?.id !== 'string' || !/^cs_(test|live)_[A-Za-z0-9]+$/.test(session.id)) throw new Error('The payment provider answered without a session');
27
+ await env.DB.prepare('INSERT OR REPLACE INTO meta(key,value) VALUES(?1,?2)').bind(META(orderId), JSON.stringify({ session: session.id, livemode: session.livemode === true })).run();
28
+ return session;
29
+ }
30
+
31
+ /**
32
+ * Asks the provider whether one booking order was paid, and records it with the shop's own `paid` checks (order,
33
+ * buyer, currency, amount and tax must match). True when the order changed.
34
+ */
35
+ export async function syncProviderOrder(env, cat, orderId) {
36
+ if (typeof orderId !== 'string' || !/^ord_[A-Za-z0-9]{8,64}$/.test(orderId) || !env?.HOMIE_PAYMENTS || !env.DB) return false;
37
+ try {
38
+ const meta = await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(META(orderId)).first();
39
+ if (!meta) return false;
40
+ const saved = JSON.parse(meta.value);
41
+ const order = await env.DB.prepare('SELECT id,session,status,mode FROM shop_orders WHERE id=?1').bind(orderId).first();
42
+ if (!order || order.session !== saved.session || !['started', 'processing'].includes(order.status)) return false;
43
+ const session = await env.HOMIE_PAYMENTS.bookingSession(order.session);
44
+ if (!session || session.id !== order.session || session.livemode !== (order.mode === 'live') || session.metadata?.order !== orderId) return false;
45
+ if (session.status !== 'complete' || !['paid', 'no_payment_required'].includes(session.payment_status)) return false;
46
+ const { paid, shopOf } = await import('./shop.mjs');
47
+ const at = (session.payment_intent?.latest_charge?.created ?? session.payment_intent?.created ?? session.created ?? Math.floor(Date.now() / 1000)) * 1000;
48
+ const outcome = await paid(env, shopOf(cat), session, at);
49
+ return outcome !== 'mismatch' && outcome !== 'unknown-order';
50
+ } catch (error) {
51
+ console.warn('Booking provider sync', { order: orderId, reason: String(error?.message ?? error).slice(0, 200) });
52
+ return false;
53
+ }
54
+ }
@@ -1,2 +1,2 @@
1
1
  // Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
2
- export const STUDIO_BUILD = Object.freeze({"version":"0.58.21","sha256":"40282a0a4a1396e3d2856ddb8dee6dbd8998ef3bdb9884d32824479b710c975f"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.58.24","sha256":"e554d72f5dbf3c334bc37e6867fc1e92465a3648996ddc0fd497d4f7c014e601"});
@@ -26,7 +26,21 @@ export function customerTools(env,cat,origin) {
26
26
  return [
27
27
  {name:'studio_catalogue',description:'Browse the public shop, paid parts and app goods. Prices use the studio’s currency minor units.',audience:'public',inputSchema:object(),handler:async(_args,ctx)=>({shop:await customerShop(env,cat,origin,ctx.caller,'/api/shop'),resources:await listOffers(env,origin)})},
28
28
  {name:'studio_cart',description:'Quote a cart for agent payment. Retain the returned private account cookies to use guest purchases. Approve this immutable offerVersion before purchase.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>quoteCart(env,cat,origin,ctx.caller,args,ctx.request)},
29
- {name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:{...cartSchema,properties:{...cartSchema.properties,reference:str,booking:object({collection:str,slot:str,hold:str,token:str},['collection','slot'])}},handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
29
+ {name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve; returns url. For a booking pass the same top-level app, hold (data.id from studio_book) and token as the venue tools: the line (the booking\'s service item × its quantity) is filled in. For goods pass lines from studio_catalogue. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:{type:'object',properties:{...cartSchema.properties,reference:str,app:str,hold:{type:'string',pattern:'^(hold_[a-f0-9]{48}|[a-f0-9]{64})$'},token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},booking:object({collection:str,slot:str,hold:str,token:str},['collection','slot'])},additionalProperties:false},handler:async(args,ctx)=>{
30
+ const coded=(code,message,status=400)=>Object.assign(new Error(message),{name:'StudioOperationError',status,code});
31
+ const {app,hold,token,...rest}=args;let body=rest;
32
+ if(hold!==undefined){
33
+ if(rest.booking)throw coded('invalid_request','Pass either hold (with token) or booking, not both');
34
+ const h=await ctx.bookings.get({id:hold,...(token?{token}:{})});
35
+ if(app!==undefined&&app!==h.app)throw coded('hold_not_found','This hold is not in that app',403);
36
+ if(!h.item)throw coded('not_bookable','This booking collection names no service item to pay for');
37
+ body={...rest,game:rest.game??h.app,lines:rest.lines??[{item:h.item,quantity:h.quantity}],booking:{collection:h.collection,slot:h.slot,hold:h.id,...(token?{token}:{})}};
38
+ }else if(!rest.lines?.length)throw coded('lines_required','Pass hold (and token) for a booking, or lines for goods');
39
+ else if(app!==undefined&&rest.game===undefined)body={...rest,game:app};
40
+ const result=await customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',body);
41
+ if(!result.ok){const {ok,error,message,...details}=result;throw Object.assign(coded(String(error??'checkout_failed').replace(/[^a-z0-9_-]/gi,'_'),message??'Checkout could not start',409),{details});}
42
+ return result;
43
+ }},
30
44
  {name:'studio_purchase',description:'Buy an approved offer with the MCP payment binding. Generate private random 32-byte hex buyer and claim values; retain the claim for retries. For x402 use the returned HTTP resource address.',audience:'public',inputSchema:{...purchaseSchema,properties:{...purchaseSchema.properties,checkout:{type:'boolean'}}},protocol:true,handler:async(args,ctx)=>{if(args.checkout&&args.kind==='cart'){const resource=await resourceKind('cart').get(env,args.resource,args.version);if(!resource)throw new Error('Cart unavailable');const result=await customerShop(env,cat,origin,{...ctx.caller,id:resource.player},'/api/shop/buy',{lines:resource.lines.map(l=>({item:l.item.id,quantity:l.quantity,amount:l.amount}))});return {content:[{type:'text',text:JSON.stringify(result)}],...(result.ok?{}:{isError:true})};}return buy(env,cat,origin,args,ctx.paymentExtra);}},
31
45
  {name:'studio_customer_refund',description:'Request a refund for your shop order under the studio refund policy.',audience:'signed-in',inputSchema:object({order:str,line:str},['order']),handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/refund',args)},
32
46
  {name:'studio_purchase_refund',description:'Request a resource or service refund with the original private purchase claim.',audience:'public',inputSchema:object({claim:{type:'string',pattern:'^[a-f0-9]{64}$'}},['claim']),handler:async({claim})=>{const url=new URL('/api/purchases/refund',origin);const response=await purchaseRoutes(new Request(url,{method:'POST',headers:{authorization:'Bearer '+claim,'content-type':'application/json'},body:'{}'}),env,url,cat);return response.json();}},
@@ -201,7 +201,7 @@ export function builtins(env,cat,origin) {
201
201
  return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
202
202
  }),
203
203
  tool('studio_availability','List available booking slots and options before studio_book. Returns schema.org Offers with exact booking arguments; availability is a snapshot, and booking rechecks capacity.','public',object({app:str,collection:str,after:str,quantity:{type:'integer',minimum:1},option:str,resource:str,addons:{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}},['app','collection']),async(args,ctx)=>ctx.bookings.availability(args)),
204
- tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
204
+ tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Give a future expiresAt and, when anonymous, a private random hex token. The returned data.id (hold_…) is the hold: pass it as hold to venue_booking_*, studio_holds and studio_checkout. Optional id is an idempotency key for retries, not the hold id. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
205
205
  const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
206
206
  if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
207
207
  const minutes=Number.isSafeInteger(spec.holdMinutes)&&spec.holdMinutes>0?Math.min(spec.holdMinutes,1440):15;
@@ -209,6 +209,14 @@ export function builtins(env,cat,origin) {
209
209
  await ctx.records({app:args.app,collection:spec.slots,id:args.slot});
210
210
  return ctx.bookings.hold(args);
211
211
  }),
212
+ tool('studio_holds','List your own booking holds (held or confirmed) with the private token you gave studio_book, or release them: release is a hold id from that list, or "all". Only that token\'s holds are ever shown or changed.','public',object({token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},app:str,release:{type:'string',pattern:'^(all|hold_[a-f0-9]{48}|[a-f0-9]{64})$'},all:{type:'boolean',description:'Include ended holds.'}}),async({token,app,release,all},ctx)=>{
213
+ const holds=await ctx.bookings.mine({token,app,active:!all||Boolean(release)});
214
+ if(!release)return {holds};
215
+ const chosen=holds.filter(h=>release==='all'?h.state==='held':h.id===release);
216
+ if(release!=='all'&&!chosen.length)throw Object.assign(new Error('No active hold with this id for this token'),{name:'StudioOperationError',status:403,code:'hold_not_found'});
217
+ const released=[];for(const h of chosen)released.push(await ctx.bookings.release({id:h.id,token,version:h.version}));
218
+ return {released};
219
+ }),
212
220
  tool('app_records','List, read, create, update or delete app records with the app role and record version.','public',object({app:str,role:str,collection:{type:'string',pattern:'^[a-z][a-z0-9-]{0,39}$'},id:{type:'string',pattern:'^[A-Za-z0-9_-]{1,64}$',description:'1–64 letters, digits, underscore or hyphen'},operation:{enum:['read','create','update','delete']},data:{type:'object',propertyNames:{pattern:'^[a-z][a-z0-9_]*$'},description:'Field names start with a lowercase letter and use lowercase letters, digits and underscore; discover fields with app_schema.'},version:{type:'integer',minimum:1}},['app','collection']), (args,ctx)=>ctx.records(args)),
213
221
  ];
214
222
  for(const [name,path,description] of [
package/worker/mcp.mjs CHANGED
@@ -159,7 +159,12 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
159
159
  const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
160
160
  const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
161
161
  outcome='ok';return {structuredContent:{kind:'untrusted-tool-data',tool:tool.name,data:value??null},content:[{type:'text',text}]};
162
- }catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
162
+ }catch(error){if(error?.code===-32042)throw error;
163
+ const shown=outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'||error?.message==='Input does not match the tool schema';
164
+ const message=shown?error.message:freshErrorMessage(error,env,caller);
165
+ // Stable, machine-readable refusal: {kind:'studio-error',error:{code,message,status}}. Codes only ever get added.
166
+ const code=typeof error?.code==='string'&&/^[a-z0-9_-]{1,64}$/.test(error.code)?error.code:outcome==='denied'?'forbidden':outcome==='limited'?'rate_limited':error?.message==='Input does not match the tool schema'?'invalid_input':error?.name==='StudioOperationError'?({400:'invalid_request',403:'forbidden',404:'not_found',409:'conflict'}[error.status]??'refused'):'internal_error';
167
+ return {isError:true,structuredContent:{kind:'studio-error',tool:tool?.name??null,error:{code,message,...(Number.isInteger(error?.status)?{status:error.status}:{}),...(shown&&error?.details&&typeof error.details==='object'?{details:error.details}:{})}},content:[{type:'text',text:message}]};}
163
168
  finally{
164
169
  try { await audit(env,caller,tool.name,outcome); }
165
170
  finally {
package/worker/shop.mjs CHANGED
@@ -6,6 +6,7 @@ import { bindSubscription, subscriptionEvent, subscriptionPortal } from './shop-
6
6
  import { bookingStatement } from './booking-store.mjs';
7
7
  import { paymentRefunds } from './shop-refunds.mjs';
8
8
  import { selling } from './extensions.mjs';
9
+ import { providerOpen, providerCheckout } from './booking-provider.mjs';
9
10
  /**
10
11
  * THE SHOP (@homie-rocks/studio 0.24.0): a studio sells with ITS OWN Stripe account (shop/SHOP.md is the guide).
11
12
  * The studio is the seller; homie.rocks never sees, holds or moves its money, and Homie takes no cut.
@@ -514,9 +515,10 @@ export async function reconcileOrders(env, shop, player = null, { expireOpen = f
514
515
  }
515
516
 
516
517
  /** Make the order row, then Stripe's Checkout Session. Returns { order, url } or a refusal Response. */
517
- async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now() }) {
518
+ async function startCheckout(env, url, cat, shop, item, { player, game, amount, parent = false, lines = null, reference = null, booking = null, requestAt = Date.now(), provider = false }) {
518
519
  lines ??= [{ item, amount, quantity: 1 }];
519
- const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
520
+ if (provider && (!booking || lines.some(l => l.item.interval))) provider = false;
521
+ const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env) && !provider;
520
522
  const config=linkConfig(env);
521
523
  if(booking?.hold&&keyless)return {error:fail(409,'booking','A held slot needs session checkout; keyless payment links cannot claim it.')};
522
524
  if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
@@ -595,9 +597,9 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
595
597
  try {
596
598
  const checkoutParams = params();
597
599
  if (booking) await env.DB.prepare('INSERT INTO meta(key,value) VALUES(?1,?2)').bind(`booking-checkout:${order.id}`, JSON.stringify(checkoutParams)).run();
598
- session = await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
600
+ session = provider ? await providerCheckout(env, order.id, checkoutParams) : await createCheckoutSession(env, checkoutParams, { idempotencyKey: `checkout-${order.id}` });
599
601
  } catch (error) {
600
- if (!useCatalog || !isMissingProduct(error)) throw error;
602
+ if (provider || !useCatalog || !isMissingProduct(error)) throw error;
601
603
  for (const line of lines) line.product = null;
602
604
  session = await createCheckoutSession(env, params(), { idempotencyKey: `checkout-${order.id}-inline` });
603
605
  }
@@ -634,7 +636,9 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
634
636
  const reference = b.body.reference ?? null;
635
637
  if (reference !== null && (typeof reference !== 'string' || !reference.trim() || /[\u0000-\u001f\u007f]/.test(reference))) return fail(400, 'reference', 'reference must be a nonempty string without control characters');
636
638
  const freeOnly = (b.body.lines ?? [{ item: b.body.item }]).every?.((l) => shop.items.some((i) => i.id === l?.item && i.price === 0 && !i.interval));
637
- if (!ready.ready && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
639
+ // A host's booking checkout provider (booking-provider.mjs) takes the place of the studio's own Stripe setup, for bookings only.
640
+ const provider = b.body.booking !== undefined && !ready.ready && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout', 'stripe-key'].includes(m)) && await providerOpen(env);
641
+ if (!ready.ready && !provider && !(freeOnly && ready.missing.every((m) => ['payment-links', 'webhook-secret', 'fuller-checkout'].includes(m)))) return fail(503, 'closed', 'The shop is not open yet.', { missing: ready.missing, next: ready.next, migration: ready.migration });
638
642
  const requested = b.body.lines ?? [{ item: b.body.item, quantity: b.body.quantity ?? 1, amount: b.body.amount }];
639
643
  // Stripe's documented limit, not a studio policy: https://docs.stripe.com/api/checkout/sessions/create#line_items
640
644
  if (!Array.isArray(requested) || !requested.length || requested.length > 100) return fail(400, 'stripe-lines', 'Stripe Checkout takes between 1 and 100 lines.');
@@ -694,7 +698,7 @@ async function buyRoute(request, env, url, cat, shop, ready, ctx, requestAt) {
694
698
  }catch{return fail(403,'booking','Booking owner required');}
695
699
  }
696
700
  }
697
- const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt });
701
+ const r = await startCheckout(env, url, cat, shop, item, { player, game, amount, lines, reference, booking, requestAt, provider: provider && !!booking });
698
702
  const response = r.error ?? json({ ok: true, order: r.order.id, url: r.url, ...(booking ? { booking: booking.hold??r.order.id } : {}) });
699
703
  if (!r.error && !r.reconciled && ctx?.waitUntil) ctx.waitUntil(reconcileOrders(env, shop, player.id).catch((error) => logOrderError(env, r.order.id, error)));
700
704
  for (const cookie of cookies) response.headers.append('set-cookie', cookie);
@@ -1,2 +1,2 @@
1
1
  /** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
2
- export const STUDIO_VERSION_TAG = '0.58.21';
2
+ export const STUDIO_VERSION_TAG = '0.58.24';