@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 +40 -0
- package/lib/parts-store.mjs +46 -2
- package/lib/template-history.json +126 -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 +61 -23
- package/worker/booking-options.mjs +1 -1
- package/worker/booking-provider.mjs +54 -0
- 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/shop.mjs +10 -6
- package/worker/version.mjs +1 -1
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**
|
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,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.
|
|
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",
|
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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
45
|
-
if(caller.
|
|
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
|
|
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
|
-
|
|
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
|
|
65
|
-
if(!Number.isSafeInteger(
|
|
66
|
-
if(
|
|
67
|
-
if(
|
|
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
|
-
|
|
79
|
+
const secret=token?await hash(token):null;
|
|
71
80
|
const choices={option:args.option??null,resource:args.resource??null,addons:args.addons??{}};
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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('
|
|
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:
|
|
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
|
+
}
|
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.24","sha256":"e554d72f5dbf3c334bc37e6867fc1e92465a3648996ddc0fd497d4f7c014e601"});
|
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/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
|
-
|
|
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
|
-
|
|
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);
|
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.24';
|