@homie-rocks/studio 0.58.21 → 0.58.23
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 +30 -0
- package/lib/parts-store.mjs +46 -2
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/parts/catalogue.json +4 -4
- package/parts/venue-desk/part.json +4 -4
- package/parts/venue-desk/src/index.ts +6 -6
- package/worker/booking-availability.mjs +1 -1
- package/worker/booking-holds.mjs +48 -21
- package/worker/booking-options.mjs +1 -1
- package/worker/build-id.mjs +1 -1
- package/worker/customer-mcp.mjs +15 -1
- package/worker/mcp-tools.mjs +9 -1
- package/worker/mcp.mjs +6 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,36 @@ 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.23] - 2026-10-29
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.15**
|
|
23
|
+
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `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.
|
|
29
|
+
- 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.
|
|
30
|
+
- `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.
|
|
31
|
+
|
|
32
|
+
### Added
|
|
33
|
+
|
|
34
|
+
- `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.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- A hold past its expiry no longer takes a place: availability stops counting it, and the next booking on that slot releases it.
|
|
39
|
+
|
|
40
|
+
## [0.58.22] - 2026-10-29
|
|
41
|
+
|
|
42
|
+
**Plugin 0.58.15**
|
|
43
|
+
|
|
44
|
+
The parts that come with the toolkit, like the Venue desk, add to a studio with no download.
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- `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.
|
|
49
|
+
|
|
20
50
|
## [0.58.21] - 2026-10-29
|
|
21
51
|
|
|
22
52
|
**Plugin 0.58.15**
|
package/lib/parts-store.mjs
CHANGED
|
@@ -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,90 @@
|
|
|
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
|
+
}
|
|
4869
4953
|
}
|
|
4870
4954
|
}
|
|
4871
4955
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.23",
|
|
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",
|
package/parts/catalogue.json
CHANGED
|
@@ -236,7 +236,7 @@
|
|
|
236
236
|
"id": "venue-desk",
|
|
237
237
|
"name": "Venue desk",
|
|
238
238
|
"kind": "tools",
|
|
239
|
-
"version": "1.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": "
|
|
311
|
-
"bytes":
|
|
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":
|
|
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.
|
|
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": "
|
|
77
|
-
"bytes":
|
|
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":
|
|
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
|
|
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
|
|
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
|
|
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)){
|
package/worker/booking-holds.mjs
CHANGED
|
@@ -35,25 +35,32 @@ ${['insert','update'].map(op=>{
|
|
|
35
35
|
}).join('\n')}
|
|
36
36
|
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
37
|
`;
|
|
38
|
-
const
|
|
38
|
+
const CODES={400:'invalid_request',403:'forbidden',404:'not_found',409:'conflict'};
|
|
39
|
+
const refusal=(message,status=400,code=CODES[status]??'invalid_request')=>Object.assign(new Error(message),{name:'StudioOperationError',status,code});
|
|
39
40
|
const valid=v=>typeof v==='string'&&/^[A-Za-z0-9_-]{1,64}$/.test(v);
|
|
41
|
+
/** 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. */
|
|
42
|
+
export const HOLD_ID=/^(hold_[a-f0-9]{48}|[a-f0-9]{64})$/;
|
|
43
|
+
const holdId=async parts=>'hold_'+(await hash(JSON.stringify(parts))).slice(0,48);
|
|
44
|
+
const randomHex=n=>Array.from(crypto.getRandomValues(new Uint8Array(n)),b=>b.toString(16).padStart(2,'0')).join('');
|
|
40
45
|
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
46
|
export async function ownedHold(env,caller,{id,token}){
|
|
42
|
-
if(!valid(id))throw refusal('Invalid
|
|
47
|
+
if(!valid(id))throw refusal('Invalid hold id: pass data.id from studio_book',400,'invalid_hold');
|
|
43
48
|
const row=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(id).first();
|
|
44
|
-
|
|
45
|
-
if(caller.
|
|
49
|
+
// One answer for "no such hold" and "not yours", so a hold id reveals nothing to someone without its token.
|
|
50
|
+
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');
|
|
51
|
+
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
52
|
return row;
|
|
47
53
|
}
|
|
48
54
|
/** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
|
|
49
55
|
export function bookingHolds(env,caller,cat){
|
|
50
|
-
const
|
|
56
|
+
const spec=r=>cat?.games?.find(g=>g.kind==='app'&&g.id===String(r.app).split(':check:')[0])?.records?.collections?.[r.collection]?.booking;
|
|
57
|
+
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
58
|
const change=async(args,state)=>{
|
|
52
59
|
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');
|
|
60
|
+
if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release',409,'checkout_owns_hold');
|
|
61
|
+
if(!Number.isSafeInteger(args.version)||args.version<1)throw refusal('Booking version required',400,'version_required');
|
|
55
62
|
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);
|
|
63
|
+
if(!changed)throw refusal('Booking changed or expired; reload',409,'hold_changed_or_expired');return result(changed);
|
|
57
64
|
};
|
|
58
65
|
return Object.freeze({
|
|
59
66
|
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});},
|
|
@@ -61,27 +68,47 @@ export function bookingHolds(env,caller,cat){
|
|
|
61
68
|
const args=normalizeBookingArgs(input);
|
|
62
69
|
let {app,collection,slot,quantity=1,id,token,expiresAt}=args;
|
|
63
70
|
const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
|
|
64
|
-
if(!spec
|
|
65
|
-
if(!Number.isSafeInteger(
|
|
66
|
-
if(
|
|
67
|
-
if(
|
|
71
|
+
if(!spec)throw refusal('Unknown booking collection',400,'unknown_collection');
|
|
72
|
+
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');
|
|
73
|
+
if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry',400,'invalid_expiry');
|
|
74
|
+
if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token',400,'invalid_token');
|
|
75
|
+
if(!caller.id&&!token)throw refusal('Booking owner or private token required',400,'token_required');
|
|
68
76
|
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
77
|
const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
|
|
70
|
-
|
|
78
|
+
const secret=token?await hash(token):null;
|
|
71
79
|
const choices={option:args.option??null,resource:args.resource??null,addons:args.addons??{}};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
80
|
+
// `id` (optional, deprecated name for an idempotency key): a retry with the same key, token and request returns the
|
|
81
|
+
// same hold. The key is scoped to its owner (account or token), so two guests' keys never collide. The hold's own
|
|
82
|
+
// id is always the returned data.id (hold_…), never the key.
|
|
83
|
+
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);
|
|
84
|
+
if(id!==undefined){
|
|
85
|
+
const legacy=await hash(JSON.stringify([scope,collection,id]));
|
|
86
|
+
for(const candidate of [await holdId([scope,collection,id,caller.id??secret]),legacy]){
|
|
87
|
+
const existing=await env.DB.prepare('SELECT * FROM shop_bookings WHERE id=?1').bind(candidate).first();
|
|
88
|
+
if(!existing)continue;
|
|
89
|
+
let previous;try{previous=await ownedHold(env,caller,{id:candidate,token});}catch{if(candidate===legacy)continue;throw refusal('Idempotency key reused',409,'key_conflict');}
|
|
90
|
+
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');}
|
|
91
|
+
if(!same(previous))throw refusal('Idempotency key reused with a different request',409,'key_conflict');
|
|
92
|
+
return result(previous);
|
|
93
|
+
}
|
|
76
94
|
}
|
|
77
|
-
|
|
78
|
-
|
|
95
|
+
id=id!==undefined?await holdId([scope,collection,id,caller.id??secret]):'hold_'+randomHex(24);
|
|
96
|
+
// An abandoned hold past its expiry stops taking a place as soon as anyone books that slot.
|
|
97
|
+
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();
|
|
98
|
+
let allocation;try{allocation=await loadAndAllocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409,'resource_unavailable');}
|
|
79
99
|
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;}
|
|
100
|
+
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
101
|
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('
|
|
102
|
+
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');
|
|
83
103
|
return result(row);
|
|
84
104
|
},
|
|
105
|
+
/** The caller's own holds: by its account, or by the private token (that token's holds only). Newest first. */
|
|
106
|
+
async mine({token,app,active=true}={}){
|
|
107
|
+
if(token!==undefined&&!(typeof token==='string'&&/^[a-f0-9]{32,128}$/.test(token)))throw refusal('Invalid private booking token',400,'invalid_token');
|
|
108
|
+
if(!token&&!caller.id)throw refusal('Pass the private token you gave studio_book',400,'token_required');
|
|
109
|
+
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??[];
|
|
110
|
+
return rows.filter(r=>!caller.testScope||r.app.endsWith(':check:'+caller.testScope)).map(result);
|
|
111
|
+
},
|
|
85
112
|
async get(args){return result(await ownedHold(env,caller,args));},
|
|
86
113
|
confirm:args=>change(args,'confirmed'),release:args=>change(args,'released'),
|
|
87
114
|
async releaseExpired({app,collection,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:
|
|
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};
|
package/worker/build-id.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.23","sha256":"d976ca461d623e45165982deaebc53e1bda766a4ccf398701f724ca80aa46d9f"});
|
package/worker/customer-mcp.mjs
CHANGED
|
@@ -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:{
|
|
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();}},
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -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.
|
|
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;
|
|
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/version.mjs
CHANGED
|
@@ -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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.23';
|