@homie-rocks/studio 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/apps/apps.ts +17 -0
  3. package/bin/homie-studio.mjs +8 -2
  4. package/docs/shop-migration.md +9 -0
  5. package/docs/toolkit-batch.md +49 -0
  6. package/functions/FUNCTIONS.md +15 -1
  7. package/guides/app/PLACES.md +23 -0
  8. package/lib/app-needs.mjs +19 -10
  9. package/lib/app-ready.mjs +1 -1
  10. package/lib/build.mjs +3 -2
  11. package/lib/check.mjs +37 -13
  12. package/lib/dev.mjs +1 -0
  13. package/lib/fork.mjs +47 -0
  14. package/lib/jobs.mjs +1 -0
  15. package/lib/mcp-tools.mjs +1 -1
  16. package/lib/needs-headless.mjs +4 -3
  17. package/lib/scaffold.mjs +8 -1
  18. package/lib/shop-links.mjs +3 -3
  19. package/lib/shop-mcp.mjs +6 -0
  20. package/lib/stripe-connect.mjs +37 -2
  21. package/lib/template-history.json +42 -0
  22. package/lib/tools-cli.mjs +25 -3
  23. package/lib/worker-config.mjs +1 -0
  24. package/package.json +1 -1
  25. package/rules/rules.ts +1 -1
  26. package/shop/shop.ts +3 -3
  27. package/tools/tools.ts +3 -2
  28. package/worker/app-format.mjs +6 -3
  29. package/worker/app-records.mjs +16 -4
  30. package/worker/app-shell.mjs +1 -1
  31. package/worker/app-tools.mjs +1 -1
  32. package/worker/app-web.mjs +23 -0
  33. package/worker/booking-calendar.mjs +26 -0
  34. package/worker/booking-holds.mjs +18 -5
  35. package/worker/booking-options.mjs +23 -0
  36. package/worker/booking-resources.mjs +55 -0
  37. package/worker/build-id.mjs +1 -1
  38. package/worker/customer-mcp.mjs +1 -1
  39. package/worker/index.mjs +36 -6
  40. package/worker/local-test.mjs +10 -0
  41. package/worker/mcp-events.mjs +2 -2
  42. package/worker/mcp-tools.mjs +20 -2
  43. package/worker/office.mjs +27 -0
  44. package/worker/records-page.mjs +2 -2
  45. package/worker/shop.mjs +6 -1
  46. package/worker/version.mjs +1 -1
@@ -96,7 +96,7 @@ export async function shopConnect(root, options = {}) {
96
96
  const verified=saved.result;
97
97
  if(!verified.verified || verified.local || verified.mode!==mode || verified.site!==new URL(siteUrl(root)).origin) return fail('verify','Run shop verify with fresh Stripe MCP reads for this site and mode first.');
98
98
  mcpAccount=saved.options.account;
99
- if(!/^acct_[A-Za-z0-9]+$/.test(mcpAccount??''))return fail('account','Verify with the Stripe MCP account ID first.');
99
+ if(mcpAccount && !/^acct_[A-Za-z0-9]+$/.test(mcpAccount))return fail('account','Invalid saved Stripe account ID.');
100
100
  catalog={config:verified.config,retire:[]};
101
101
  } catch { return fail('verify','Run shop verify with fresh Stripe MCP reads for this site first.'); }
102
102
  }
@@ -126,6 +126,7 @@ export async function shopConnect(root, options = {}) {
126
126
  if (start.code !== 0 || !stripeURL(login?.browser_url) || !complete || !/^[a-zA-Z0-9 -]{1,100}$/.test(login?.verification_code ?? '')) {
127
127
  return fail('stripe-login', 'Stripe could not start browser approval. Update the official Stripe CLI and try shop connect again. An account administrator may need to enable CLI access in Stripe’s MCP and CLI access settings.' + stripeReason(start, 'login'));
128
128
  }
129
+ options.onEvent?.({type:'stripe.pairing',code:login.verification_code,url:login.browser_url});
129
130
  log(`Approve your studio’s Stripe account in your browser. Pairing code: ${login.verification_code}. ${login.browser_url}`);
130
131
  open(login.browser_url);
131
132
  const finished = await cli(complete);
@@ -136,7 +137,23 @@ export async function shopConnect(root, options = {}) {
136
137
  }
137
138
  if (options.webhookOnly) {
138
139
  const accountResult=await cli(['get','/v1/account',...(live?['--live']:[])]);
139
- if(accountResult.code!==0 || json(accountResult.stdout)?.id!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
140
+ const account=json(accountResult.stdout)?.id;
141
+ if(accountResult.code!==0 || !/^acct_[A-Za-z0-9]+$/.test(account??'') || mcpAccount && account!==mcpAccount) return fail('account','Stripe CLI and MCP must select the same account. Verify with the MCP account ID first.');
142
+ if(!mcpAccount) {
143
+ // Old verifications predate account IDs. Establish ownership before adopting
144
+ // the CLI account; never silently bind a different account's catalog.
145
+ const links=[...(catalog.config.items??[]),...(catalog.config.menus??[]),...(catalog.config.slots??[])];
146
+ for(const link of links) {
147
+ const read=await cli(['get',`/v1/payment_links/${link.id}`,...(live?['--live']:[])]);
148
+ const actual=json(read.stdout);
149
+ if(read.code!==0 || actual?.id!==link.id || actual.url!==link.url || actual.livemode!==(mode==='live'))return fail('account','The signed-in Stripe account does not own the verified links. Select their account and retry.');
150
+ }
151
+ const file=join(root,'.studio','stripe-mcp.json');
152
+ const saved=JSON.parse(readFileSync(file,'utf8'));
153
+ saved.options={...saved.options,account};
154
+ writeFileSync(file,JSON.stringify(saved,null,2));
155
+ mcpAccount=account;
156
+ }
140
157
  }
141
158
  const site = siteUrl(root);
142
159
  if (!site) return fail('deploy', 'Stripe is connected on this computer. Deploy the studio with studio_deploy (Cloudflare approval first if needed), then run shop connect again.', { mode, connected: true });
@@ -156,6 +173,24 @@ export async function shopConnect(root, options = {}) {
156
173
  const reason = fullerReason(checked.shop);
157
174
  if (reason) return fail('fuller-checkout', reason, { mode, connected: true });
158
175
  const studio = readStudio(root);
176
+ if(options.webhookOnly && catalog.config.portal && !catalog.config.portalId) {
177
+ // 0.51.6 saved the login URL alone. Discover its identity without creating
178
+ // a second portal or making webhook installation depend on this new field.
179
+ try {
180
+ let after;
181
+ for(let page=0;page<100;page++) {
182
+ const found=await api('GET','/v1/billing_portal/configurations',{limit:100,...(after?{starting_after:after}:{})});
183
+ const portal=found.data?.find(p=>p.login_page?.url===catalog.config.portal && p.metadata?.homie==='shop-links-v1' && p.metadata?.studio===studio.slug && p.metadata?.origin===target.origin);
184
+ if(portal) {
185
+ catalog.config.portalId=portal.id;
186
+ const file=join(root,'.studio','stripe-mcp.json'),saved=JSON.parse(readFileSync(file,'utf8'));
187
+ saved.result.config.portalId=portal.id;writeFileSync(file,JSON.stringify(saved,null,2));break;
188
+ }
189
+ if(!found.has_more||!found.data?.length)break;
190
+ after=found.data.at(-1).id;
191
+ }
192
+ } catch { log('The existing billing portal URL is retained. The next Stripe plugin observation can fill its portal ID.'); }
193
+ }
159
194
  const w = wrangler ?? runner(root, studio.cloudflare?.accountId ? { CLOUDFLARE_ACCOUNT_ID: studio.cloudflare.accountId } : {});
160
195
  const receiptFile = join(dir, `${mode}.json`);
161
196
  const receipt = existsSync(receiptFile) ? json(readFileSync(receiptFile, 'utf8')) : null;
@@ -3921,6 +3921,48 @@
3921
3921
  }
3922
3922
  }
3923
3923
  }
3924
+ },
3925
+ "0.53.0": {
3926
+ "files": {
3927
+ "CLAUDE.md": "ddf2b2a0376202f6",
3928
+ "README.md": "684b3cca8001673c",
3929
+ "HANDOFF.md": "29337e2ace95b2e7",
3930
+ "games/README.md": "5ad32fe80c61f117",
3931
+ "music/README.md": "a3dc042dadd39df8",
3932
+ "videos/README.md": "16babc3437d5545b",
3933
+ "posts/README.md": "37841aae476bd540",
3934
+ "site/README.md": "1dbdf22c03a1e505",
3935
+ "site/src/worker.mjs": "6cc5c6131eaf6fd0"
3936
+ },
3937
+ "sections": {
3938
+ "AGENTS.md": {
3939
+ "(intro)": "394cbbe934e80722",
3940
+ "## Version-matched authoring guides": "f6261a0e5801b96b",
3941
+ "## Layout": "f3a280b8c96cd068",
3942
+ "## Apps: one screen, roles and parts": "c10aa0441ea89702",
3943
+ "## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
3944
+ "## The site": "0cb8dd9a6f8a0096",
3945
+ "## Making games": "bb715272fd1a615b",
3946
+ "## The Game Codex and progress": "0738ef719cef3a4c",
3947
+ "## Art direction and models": "a48fff7bf0a5f2fc",
3948
+ "## Running live games (the back office)": "51ebf2ab756f6e4d",
3949
+ "## Servers and AI seats": "1ccfd549d2369a60",
3950
+ "## Selling things (the shop)": "0df5a5fe1833c624",
3951
+ "## Continuing a build from the Claude app": "0d68081e924ca419",
3952
+ "## Rules": "b2234d80f6cbe71d",
3953
+ "## Telling Homie": "80b46fdd9e578db8",
3954
+ "## Beta": "9d02638207a26e73"
3955
+ }
3956
+ },
3957
+ "named": {
3958
+ "Homie Arcade": {
3959
+ "sections": {
3960
+ "AGENTS.md": {
3961
+ "## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
3962
+ }
3963
+ }
3964
+ }
3965
+ }
3924
3966
  }
3925
3967
  }
3926
3968
  }
package/lib/tools-cli.mjs CHANGED
@@ -16,9 +16,10 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
16
16
  const {Client,StreamableHTTPClientTransport,auth}=await import('mcp-studio-client');
17
17
  const url=new URL('/mcp',site);if(url.protocol!=='https:'&&!['localhost','127.0.0.1','[::1]'].includes(url.hostname))throw new Error('Use HTTPS, or localhost for local development');
18
18
  const client=new Client({name:'Homie studio tool CLI',version:'1.0.0'});
19
- if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url));return client;}
19
+ const fetch=startupFetch({log});
20
+ if(publicOnly){await client.connect(new StreamableHTTPClientTransport(url,{fetch}));return client;}
20
21
  const secret=localCredential(root,url);
21
- if(secret){await client.connect(new StreamableHTTPClientTransport(url,{requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
22
+ if(secret){await client.connect(new StreamableHTTPClientTransport(url,{fetch,requestInit:{headers:{authorization:`Bearer ${secret}`}}}));return client;}
22
23
  const file=join(root,'.studio','mcp',Buffer.from(url.origin).toString('base64url')+'.json');
23
24
  let state={};try{state=JSON.parse(readFileSync(file,'utf8'));}catch{}
24
25
  // Credentials without an issuer predate the SDK's OAuth binding fix; reconnect safely.
@@ -46,7 +47,7 @@ export async function connectToolClient(root,site,{publicOnly=false,open=default
46
47
  if(back.searchParams.has('error'))throw new Error('Connection declined');
47
48
  await auth(provider,{serverUrl:url,authorizationCode:back.searchParams.get('code'),iss:back.searchParams.get('iss')??undefined});
48
49
  }
49
- await client.connect(new StreamableHTTPClientTransport(url,{authProvider:provider}));return client;
50
+ await client.connect(new StreamableHTTPClientTransport(url,{fetch,authProvider:provider}));return client;
50
51
  }finally{server.close();}
51
52
  }
52
53
  function defaultOpen(url){const cmd=process.platform==='darwin'?'open':process.platform==='win32'?'explorer':'xdg-open';const child=spawn(cmd,[url],{stdio:'ignore',detached:true});child.unref();}
@@ -56,3 +57,24 @@ export async function toolCommand(root,sub,positional,flags,{log=()=>{}}={}) {
56
57
  const client=await connectToolClient(root,flags.get('url')??'http://localhost:8787',{publicOnly:flags.has('public'),log});
57
58
  try{const result=sub==='list'?await client.listTools():await client.callTool({name:positional[2],arguments:JSON.parse(flags.get('input')??'{}')});return {ok:result.isError!==true,command:'tool '+sub,result,...(result.isError?{why:'The tool refused or failed; see its result.'}:{})};}finally{await client.close();}
58
59
  }
60
+
61
+ /** A new Worker can briefly return Cloudflare 1042 before MCP starts. */
62
+ export function startupFetch({fetch: send=globalThis.fetch,log=()=>{},sleep=ms=>new Promise(resolve=>setTimeout(resolve,ms))}={}) {
63
+ return async(input,init)=>{
64
+ const request=new Request(input,init);
65
+ for(let attempt=0;;attempt++) {
66
+ const response=await send(request.clone());
67
+ if(response.status!==500 || attempt>=3)return response;
68
+ const reader=response.clone().body?.getReader();
69
+ let text='';
70
+ if(reader)try {
71
+ const decoder=new TextDecoder();
72
+ while(text.length<4096) {const {value,done}=await reader.read();if(done)break;text+=decoder.decode(value).slice(0,4096-text.length);}
73
+ }finally{void reader.cancel().catch(()=>{});}
74
+ if(!/error code:\s*1042\b/i.test(text))return response;
75
+ log(`Cloudflare 1042 during Worker startup; retrying MCP (${attempt+1}/3).`);
76
+ await response.body?.cancel();
77
+ await sleep(250*2**attempt);
78
+ }
79
+ };
80
+ }
@@ -27,6 +27,7 @@ export function updateWorkerConfig(root,generated) {
27
27
  writeFileSync(join(root,'wrangler.custom.json'),JSON.stringify(custom,null,2)+'\n');
28
28
  }
29
29
  const next=merge(base,custom);
30
+ next.vars={...next.vars,HOMIE_CONCEPT:readStudio(root).concept===true?'1':'0'};
30
31
  verifiedMcpConfig(root);
31
32
  if(publicMcpSetup(root)) {
32
33
  next.vars={...next.vars,STRIPE_CHECKOUT_MODE:'links',STRIPE_SHOP_LINKS_ASSET:'/_site/stripe-shop-links.json'};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@homie-rocks/studio",
3
- "version": "0.52.0",
3
+ "version": "0.53.0",
4
4
  "description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/rules/rules.ts CHANGED
@@ -607,7 +607,7 @@ export function compileRules(def: RulesDef, env: CompileEnv = {}): Compiled {
607
607
  if (dims === 3 && b.height !== undefined && (!Number.isFinite(b.height) || b.height <= 0 || b.shape === 'capsule' && b.height < b.radius * 2)) throw new Error(`${at}.body.height must be finite, positive and at least twice the radius for a capsule`);
608
608
  body = { shape: b.shape, radius: b.radius, height: dims === 3 && b.shape === 'sphere' ? 2 * b.radius : typeof b.height === 'number' && b.height > 0 ? b.height : 0, maxSpeed: b.maxSpeed, sweep: b.sweep === true, owner: b.move === 'owner', ...(b.terrain === 'exact' ? {terrain: 'exact' as const} : {}) };
609
609
  }
610
- if (player && !body) throw new Error(`${at}: a player's kind needs a body`);
610
+ // Turn-based participants may own fields and commands without physics.
611
611
  const moveFn = move && typeof move[name] === 'function' ? move[name] as MoveFn : null;
612
612
  if (body && player && !moveFn) throw new Error(`${at}: a player's body moves with move.${name}(body, input, ctx) in move.ts`);
613
613
  const scores = fields.filter(([, fd]) => fd.score);
package/shop/shop.ts CHANGED
@@ -71,7 +71,7 @@ export interface Shop {
71
71
  cart(): CartLine[];
72
72
  clear(): void;
73
73
  /** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
74
- book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
74
+ book(item: string, booking: { collection: string; slot: string; hold?: string; token?: string }, quantity?: number, options?: { reference?: string }): Promise<{ ok: boolean; booking?: string; error?: string }>;
75
75
  checkout(options?: { reference?: string }): Promise<boolean>;
76
76
  buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
77
77
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
@@ -154,8 +154,8 @@ export function createShop(opts: ShopOptions = {}): Shop {
154
154
  add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
155
155
  cart: () => cart.map((line) => ({ ...line })),
156
156
  clear: () => { cart = []; },
157
- book: async (item, booking, quantity = 1) => {
158
- const r = await send({ op: 'checkout', booking, lines: [{ item, quantity }] });
157
+ book: async (item, booking, quantity = 1, options = {}) => {
158
+ const r = await send({ op: 'checkout', booking, reference: options.reference, lines: [{ item, quantity }] });
159
159
  return { ok: Boolean(r.ok), ...(typeof r.booking === 'string' ? { booking: r.booking } : {}), ...(typeof r.message === 'string' ? { error: r.message } : {}) };
160
160
  },
161
161
  checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
package/tools/tools.ts CHANGED
@@ -7,7 +7,8 @@ export interface ToolContext {
7
7
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
8
  shop(): Promise<unknown>;
9
9
  bookings: {
10
- hold(args: {app: string; collection: string; slot: string; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
10
+ calendar(args: {app:string;startDate:string;endDate:string;timeZone?:string;time?:string;days?:number;durationMinutes?:number;capacity?:number;weekdays?:number[];resources?:string[];options?:unknown[];prefix?:string}): Array<{id:string;start:string;end:string;capacity:number;date:string;timeZone:string}>;
11
+ hold(args: {app: string; collection: string; slot?: string; time?: string; option?: string; resource?: string; preferredResource?: string; addons?: string[] | Record<string,number>; addOns?: string[] | Record<string,number>; quantity?: number; id: string; token?: string; expiresAt: number}): Promise<BookingHold>;
11
12
  get(args: {id: string; token?: string}): Promise<BookingHold>;
12
13
  confirm(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
13
14
  release(args: {id: string; token?: string; version: number}): Promise<BookingHold>;
@@ -47,4 +48,4 @@ export interface StudioTool<Input = Record<string, unknown>> {
47
48
  }
48
49
  export function defineTool<Input = Record<string, unknown>>(tool: StudioTool<Input>): StudioTool<Input> { return tool; }
49
50
 
50
- export interface BookingHold { id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
51
+ export interface BookingHold { allocations?: Array<{resource:string;collection:string;start:number;end:number;units:number}>; choices?: {option:string|null;resource:string|null;addons:Record<string,number>}; id: string; slot: string; quantity: number; state: string; version: number; expiresAt: number | null; paid: boolean }
@@ -6,6 +6,7 @@ const object = (x) => x && typeof x === 'object' && !Array.isArray(x);
6
6
  export function appProblems(meta) {
7
7
  const bad = [];
8
8
  if (!object(meta)) return ['app.json must be an object'];
9
+ if(meta.web!==undefined && (!object(meta.web)||!object(meta.web.manifest)||meta.web.serviceWorker!==undefined&&(typeof meta.web.serviceWorker!=='string'||meta.web.serviceWorker.includes('..')||!/^[-\w/]+\.js$/.test(meta.web.serviceWorker))))bad.push('web needs a manifest object and optional relative serviceWorker .js path');
9
10
  const roles = object(meta.roles) ? meta.roles : {};
10
11
  const collections = object(meta.records?.collections) ? meta.records.collections : {};
11
12
  if (!object(meta.roles) || !Object.keys(meta.roles).length) bad.push('roles must declare at least one role');
@@ -18,13 +19,15 @@ export function appProblems(meta) {
18
19
  if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
19
20
  for (const [id, c] of Object.entries(collections)) {
20
21
  if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
21
- for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
22
+ for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean', 'array', 'object'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number, boolean, array or object type`);
22
23
  }
23
24
  for (const [id, c] of Object.entries(collections)) if (c.booking) {
24
25
  const spec = c.booking;
25
26
  if (!object(spec) || !key.test(spec.slots ?? '') || !collections[spec.slots] || typeof spec.item !== 'string' || !spec.item.trim()) bad.push(`collection ${id}: booking needs slots collection and shop item`);
27
+ if(spec.resources!==undefined&&(!key.test(spec.resources)||!collections[spec.resources]))bad.push(`collection ${id}: booking needs a declared resources collection`);
28
+ if(spec.holdMinutes!==undefined&&(!Number.isSafeInteger(spec.holdMinutes)||spec.holdMinutes<1||spec.holdMinutes>1440))bad.push(`collection ${id}: holdMinutes is 1–1440`);
26
29
  if (!meta.records.persist) bad.push(`collection ${id}: bookings need persistent records`);
27
- for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}`))) bad.push(`collection ${id}: booking and slot writes require a signed-in staff role`);
30
+ for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}` || spec.resources&&cap === `${op}:${spec.resources}`))) bad.push(`collection ${id}: booking, slot and resource writes require a signed-in staff role`);
28
31
  }
29
32
  for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
30
33
  for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
@@ -43,7 +46,7 @@ export function recordProblem(collection, data, { create = false } = {}) {
43
46
  for (const [k, s] of Object.entries(collection.fields)) {
44
47
  const v = data[k];
45
48
  if (v === undefined) { if (s.required) return `missing ${k}`; continue; }
46
- if (typeof v !== s.type || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
49
+ if ((s.type==='array'?!Array.isArray(v):s.type==='object'?!object(v):typeof v !== s.type) || (s.type === 'number' && !Number.isFinite(v)) || (typeof v === 'string' && v.length > (s.maxLength ?? 256)) || (s.enum && !s.enum.includes(v)) || (create && s.initial !== undefined && v !== s.initial)) return `invalid ${k}`;
47
50
  }
48
51
  return null;
49
52
  }
@@ -47,7 +47,7 @@ export async function appAccess(request, env, meta, url) {
47
47
  const caller = toolIdentity(request);
48
48
  if (caller) {
49
49
  if (caller.owner) return { ok: true, role, can: declared.can };
50
- if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
50
+ if((env.HOMIE_LOCAL_TEST_SECRET || caller.remoteTest) && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
51
51
  if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
52
52
  const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
53
53
  return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
@@ -91,7 +91,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
91
91
  if (!access.ok) return fail(access.status, access.error);
92
92
  const [, collection, id] = match;
93
93
  const session = await localSession(request, env);
94
- const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
94
+ const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET || toolIdentity(request)?.remoteTest ? toolIdentity(request)?.testScope : null);
95
95
  const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
96
96
  const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
97
97
  if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
@@ -118,7 +118,7 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
118
118
  }
119
119
  return json({ok:true,records});
120
120
  }
121
- if (!id) return fail(400, 'record id required');
121
+ if (!id) return fail(400, `Put the record id in the URL: /${meta.id}/api/app/records/${collection}/<id> (1–64 letters, digits, underscore or hyphen).`);
122
122
  const body = await bodyOf(request);
123
123
  if (meta.records.collections[collection].booking) {
124
124
  if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
@@ -132,7 +132,19 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
132
132
  ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) VALUES (?1, ?2, ?3, ?4, 1, ?5)').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
133
133
  : op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
134
134
  : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
135
- const result = await stmt.run();
135
+ const bookingCollections=Object.entries(meta.records.collections).filter(([,c])=>c.booking?.slots===collection);
136
+ const writes=[stmt];
137
+ if(!testScope&&op!=='delete')for(const [bookings,c] of bookingCollections) {
138
+ const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===id);
139
+ if(link&&link.capacity===body.data?.capacity)continue;
140
+ const expected=op==='create'?1:body.version+1;
141
+ writes.push(env.DB.prepare(`INSERT OR IGNORE INTO studio_events(id,type,data,at)
142
+ SELECT ?1,'booking.slots_need_payment_links',json_object('app',?2,'slots',json_array(json_object('app',?2,'collection',?3,'slots',?4,'slot',?5,'item',?6,'capacity',json_extract(value,'$.capacity')))),?7
143
+ FROM app_records WHERE app=?8 AND collection=?4 AND id=?5 AND version=?9
144
+ AND EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.slots_need_payment_links')`)
145
+ .bind(`slot-links:${recordApp}:${collection}:${id}:${expected}`,meta.id,bookings,collection,id,c.booking.item,Date.now(),recordApp,expected));
146
+ }
147
+ const result=writes.length>1?(await env.DB.batch(writes))[0]:await stmt.run();
136
148
  return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed or already exists; reload');
137
149
  } catch (error) {
138
150
  if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
@@ -2,7 +2,7 @@
2
2
  export const APP_SHELL_JS = String.raw`(function () {
3
3
  var boot = window.__HOMIE_PLAY || window.__HOMIE_APP || window.__HOMIE_WATCH;
4
4
  if (!boot || boot.kind !== 'app') return;
5
- var frame = document.querySelector('iframe.game') || document.querySelector('iframe');
5
+ var frame = boot.direct ? {contentWindow:window} : document.querySelector('iframe.game') || document.querySelector('iframe');
6
6
  var params = new URLSearchParams(location.search);
7
7
  // Share short-lived reads across same-origin screens with the exact same role/access.
8
8
  // No records are persisted. Writes always reach the Worker and invalidate all views.
@@ -14,7 +14,7 @@ export async function appCaller(request,env,meta,url){
14
14
  const access=await appAccess(request,env,meta,url);
15
15
  if(!access.ok)throw Object.assign(new Error(access.error),{status:access.status});
16
16
  const session=await localSession(request,env);
17
- if(session?.app===meta.id)return {id:`local-test-${session.app}-${session.role}`,owner:session.role==='owner',client:null,localTest:true,roles:{[meta.id]:[session.role]},testApp:meta.id,testScope:session.scope};
17
+ if(session?.app===meta.id)return {id:`local-test-${session.app}-${session.role}`,owner:!session.remoteTest&&session.role==='owner',client:null,localTest:true,...(session.remoteTest?{remoteTest:true}:{}),roles:{[meta.id]:[session.role]},testApp:meta.id,testScope:session.scope};
18
18
  const person=await players.of(request,env);
19
19
  const owner=await isOwner(request,env);
20
20
  const roles={};
@@ -0,0 +1,23 @@
1
+ import {APP_SHELL_JS} from './app-shell.mjs';
2
+ /** Explicit same-origin, public app surface for installation. */
3
+ export async function appWeb(request,env,meta,url,sub,document) {
4
+ if(!meta.web?.manifest)return null;
5
+ const base=`/${meta.id}/`;
6
+ if(sub==='manifest.webmanifest')return Response.json({...meta.web.manifest,id:base,start_url:base+'install',scope:base,display:meta.web.manifest.display??'standalone'},{headers:{'content-type':'application/manifest+json'}});
7
+ if(sub==='service-worker.js') {
8
+ const file=meta.web.serviceWorker;
9
+ if(typeof file!=='string'||file.includes('..')||!/^[-\w/]+\.js$/.test(file))return new Response('not found',{status:404});
10
+ const res=await env.ASSETS.fetch(new Request(url.origin+`/games/${meta.id}/${file}`));
11
+ return new Response(res.body,{status:res.status,headers:{'content-type':'text/javascript','service-worker-allowed':base,'cache-control':'no-cache'}});
12
+ }
13
+ if(sub!=='install')return null;
14
+ const role=meta.surfaces?.phone;
15
+ if(meta.room||meta.roles?.[role]?.signIn!==false)return new Response('Install requires a public records-only app',{status:409});
16
+ const response=await document(),headers=new Headers(response.headers);
17
+ if(!response.ok)return response;
18
+ headers.set('content-security-policy',"frame-ancestors 'self'");
19
+ const boot=JSON.stringify({kind:'app',direct:true,game:meta.id}).replace(/</g,'\\u003c');
20
+ const tags=`<base href="${base}__game/"><link rel="manifest" href="${base}manifest.webmanifest"><script>window.__HOMIE_APP=${boot};</script><script>${APP_SHELL_JS}</script>${meta.web.serviceWorker?`<script>if('serviceWorker' in navigator)navigator.serviceWorker.register('${base}service-worker.js',{scope:'${base}'}).catch(console.error);</script>`:''}`;
21
+ const html=await response.text();
22
+ return new Response(/<head[^>]*>/i.test(html)?html.replace(/<head[^>]*>/i,m=>m+tags):tags+html,{headers});
23
+ }
@@ -0,0 +1,26 @@
1
+ const dayMs=86400000;
2
+ const date=value=>{if(!/^\d{4}-\d{2}-\d{2}$/.test(value??''))throw new Error('Use a calendar date YYYY-MM-DD');const stamp=Date.parse(value+'T00:00:00Z');if(!Number.isFinite(stamp)||new Date(stamp).toISOString().slice(0,10)!==value)throw new Error('Invalid calendar date');return stamp;};
3
+ /** Resolve wall time, refusing DST gaps and ambiguous times instead of silently moving a booking. */
4
+ function instant(day,time,format) {
5
+ const wanted=day+'T'+time,base=Date.parse(wanted+':00Z');
6
+ const parts=stamp=>Object.fromEntries(format.formatToParts(stamp).map(p=>[p.type,p.value]));
7
+ const local=stamp=>{const p=parts(stamp);return `${p.year}-${p.month}-${p.day}T${p.hour}:${p.minute}`;};
8
+ const offsets=new Set([-dayMs,0,dayMs].map(delta=>{const p=parts(base+delta);return Date.UTC(+p.year,+p.month-1,+p.day,+p.hour,+p.minute)-(base+delta);}));
9
+ const matches=[...offsets].map(offset=>base-offset).filter(stamp=>local(stamp)===wanted);
10
+ if(matches.length!==1)throw new Error(`${wanted} does not exist or is ambiguous in this time zone; choose another time or publish an explicit offset timestamp`);
11
+ return matches[0];
12
+ }
13
+ /** Inclusive season dates; each returned slot may span several local calendar days. */
14
+ export function calendarSlots({startDate,endDate,timeZone,time='09:00',days,durationMinutes=60,capacity=1,weekdays,resources,options,prefix='slot'}) {
15
+ const start=date(startDate),end=date(endDate);
16
+ if(end<start||end-start>3660*dayMs||!/^([01]\d|2[0-3]):[0-5]\d$/.test(time)||!Number.isSafeInteger(capacity)||capacity<1||days!==undefined&&(!Number.isSafeInteger(days)||days<1)||!Number.isSafeInteger(durationMinutes)||durationMinutes<1)throw new Error('Invalid calendar range, time, duration or capacity');
17
+ if(weekdays&&(!Array.isArray(weekdays)||weekdays.some(n=>!Number.isInteger(n)||n<0||n>6)))throw new Error('weekdays uses 0 (Sunday) through 6 (Saturday)');
18
+ if(typeof timeZone!=='string'||!timeZone)throw new Error('Declare the place timeZone');
19
+ const format=new Intl.DateTimeFormat('en-CA',{timeZone,year:'numeric',month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',hourCycle:'h23'}),slots=[];
20
+ for(let at=start;at<=end;at+=dayMs) {
21
+ if(weekdays&&!weekdays.includes(new Date(at).getUTCDay()))continue;
22
+ const day=new Date(at).toISOString().slice(0,10),from=instant(day,time,format),to=days?instant(new Date(at+days*dayMs).toISOString().slice(0,10),time,format):from+durationMinutes*60000;
23
+ slots.push({id:`${prefix}-${day}-${time.replace(':','')}`,date:day,timeZone,start:new Date(from).toISOString(),end:new Date(to).toISOString(),capacity,...(resources?{resources}:{}),...(options?{options}:{})});
24
+ }
25
+ return slots;
26
+ }
@@ -1,3 +1,6 @@
1
+ import {calendarSlots} from './booking-calendar.mjs';
2
+ import {allocateResources} from './booking-resources.mjs';
3
+ import {normalizeBookingArgs} from './booking-options.mjs';
1
4
  import {BOOKINGS_MIGRATION} from './booking-store.mjs';
2
5
  import {linkConfig,hasStripeKey} from './shop-links.mjs';
3
6
  export const BOOKING_HOLDS_MIGRATION_FILE='0029_booking_holds.sql';
@@ -44,7 +47,7 @@ export async function ownedHold(env,caller,{id,token}){
44
47
  }
45
48
  /** A hold is the very row checkout later confirms. Secrets never enter its public projection. */
46
49
  export function bookingHolds(env,caller,cat){
47
- const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null});
50
+ const result=r=>({id:r.id,slot:r.slot,quantity:r.quantity,state:r.state,version:r.version,expiresAt:r.hold_until,paid:r.paid_at!==null,...(r.allocation&&r.allocation!=='[]'?{allocations:JSON.parse(r.allocation)}:{}),...(r.choices&&r.choices!=='{}'?{choices:JSON.parse(r.choices)}:{})});
48
51
  const change=async(args,state)=>{
49
52
  const row=await ownedHold(env,caller,args);
50
53
  if(row.order_id!=='hold:'+row.id)throw refusal('Checkout owns this booking; reconcile payment before release');
@@ -53,7 +56,10 @@ export function bookingHolds(env,caller,cat){
53
56
  if(!changed)throw refusal('Booking changed or expired; reload',409);return result(changed);
54
57
  };
55
58
  return Object.freeze({
56
- async hold({app,collection,slot,quantity=1,id,token,expiresAt}){
59
+ 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
+ async hold(input){
61
+ const args=normalizeBookingArgs(input);
62
+ let {app,collection,slot,quantity=1,id,token,expiresAt}=args;
57
63
  const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
58
64
  if(!spec||!valid(id)||!valid(slot)||!Number.isSafeInteger(quantity)||quantity<1)throw refusal('Choose a declared slot, positive quantity and stable booking id');
59
65
  if(!Number.isSafeInteger(expiresAt)||expiresAt<=Date.now())throw refusal('Hold needs a future expiry');
@@ -62,11 +68,18 @@ export function bookingHolds(env,caller,cat){
62
68
  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');
63
69
  const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
64
70
  id=await hash(JSON.stringify([scope,collection,id]));
71
+ const choices={option:args.option??null,resource:args.resource??null,addons:args.addons??{}};
72
+ if(await env.DB.prepare('SELECT id FROM shop_bookings WHERE id=?1').bind(id).first()) {
73
+ const previous=await ownedHold(env,caller,{id,token});
74
+ if(previous.app!==scope||previous.collection!==collection||previous.slot!==slot||previous.quantity!==quantity||JSON.stringify({...choices,...JSON.parse(previous.choices??'{}')})!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
75
+ return result(previous);
76
+ }
77
+ let allocation;try{allocation=await allocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
65
78
  const secret=token?await hash(token):null;
66
- 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)
67
- SELECT ?1,?2,?3,?4,?5,?6,'hold:'||?1,'held',?7,?8,?9,?10 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).run();}catch(error){if(/booking capacity unavailable/.test(error.message))throw refusal('That slot is full. No place has been taken.',409);throw error;}
79
+ 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;}
68
81
  const row=await ownedHold(env,caller,{id,token});
69
- if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity)throw refusal('Booking id reused with different request',409);
82
+ if(row.app!==scope||row.collection!==collection||row.slot!==slot||row.quantity!==quantity||JSON.stringify(JSON.parse(row.choices??'{}'))!==JSON.stringify(choices))throw refusal('Booking id reused with different request',409);
70
83
  return result(row);
71
84
  },
72
85
  async get(args){return result(await ownedHold(env,caller,args));},
@@ -0,0 +1,23 @@
1
+ /** Shared by trusted booking tools and phone flows. Aliases never silently win. */
2
+ export function normalizeBookingArgs(input) {
3
+ const args={...input};
4
+ for(const [canonical,aliases] of [['slot',['time']],['resource',['preferredResource','preferred_resource']],['addons',['addOns','add-ons']]]) {
5
+ for(const alias of aliases)if(args[alias]!==undefined) {
6
+ if(args[canonical]!==undefined&&JSON.stringify(args[canonical])!==JSON.stringify(args[alias]))throw Object.assign(new Error(`conflicting ${canonical}/${alias}`),{name:'StudioOperationError',status:400});
7
+ args[canonical]=args[alias];delete args[alias];
8
+ }
9
+ }
10
+ if(args.addons!==undefined) {
11
+ const list=Array.isArray(args.addons)?args.addons.map(id=>[id,1]):Object.entries(args.addons??{}),addons={};
12
+ for(const [id,count] of list) {
13
+ if(!/^[a-z][a-z0-9-]{0,39}$/.test(id)||!Number.isSafeInteger(count)||count<1||count>100)throw new Error('Invalid add-on id or quantity');
14
+ addons[id]=(addons[id]??0)+count;
15
+ }
16
+ args.addons=Object.fromEntries(Object.entries(addons).sort(([a],[b])=>a.localeCompare(b)));
17
+ }
18
+ return args;
19
+ }
20
+
21
+ const text={type:'string',maxLength:64};
22
+ const addons={oneOf:[{type:'array',maxItems:100,items:text},{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}]};
23
+ export const bookingInputSchema={type:'object',properties:{app:text,collection:text,slot:text,time:text,option:text,resource:text,preferredResource:text,preferred_resource:text,addons,addOns:addons,'add-ons':addons,quantity:{type:'integer',minimum:1},id:text,token:{type:'string',pattern:'^[a-f0-9]{32,128}$'},expiresAt:{type:'integer'}},required:['app','collection','id','expiresAt'],additionalProperties:false};
@@ -0,0 +1,55 @@
1
+ export const BOOKING_RESOURCES_MIGRATION_FILE='0030_booking_resources.sql';
2
+ const capacityCheck=`
3
+ SELECT RAISE(ABORT,'resource capacity unavailable') WHERE EXISTS (
4
+ WITH wanted AS (SELECT json_extract(value,'$.resource') resource,json_extract(value,'$.collection') collection,json_extract(value,'$.start') start,json_extract(value,'$.end') end,json_extract(value,'$.units') units FROM json_each(NEW.allocation)),
5
+ existing AS (SELECT json_extract(a.value,'$.resource') resource,json_extract(a.value,'$.collection') collection,json_extract(a.value,'$.start') start,json_extract(a.value,'$.end') end,json_extract(a.value,'$.units') units FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=NEW.app AND b.id!=NEW.id AND b.state IN ('held','confirmed')),
6
+ points AS (SELECT resource,collection,start point FROM wanted UNION SELECT resource,collection,start FROM existing)
7
+ SELECT 1 FROM points p WHERE EXISTS(SELECT 1 FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point)
8
+ AND (COALESCE((SELECT SUM(units) FROM wanted w WHERE w.resource=p.resource AND w.collection=p.collection AND w.start<=p.point AND w.end>p.point),0)+COALESCE((SELECT SUM(units) FROM existing e WHERE e.resource=p.resource AND e.collection=p.collection AND e.start<=p.point AND e.end>p.point),0))>
9
+ COALESCE((SELECT json_extract(value,'$.capacity') FROM app_records WHERE app=NEW.app AND collection=p.collection AND id=p.resource),0));`;
10
+ export const BOOKING_RESOURCES_MIGRATION=`ALTER TABLE shop_bookings ADD COLUMN allocation TEXT NOT NULL DEFAULT '[]';
11
+ ALTER TABLE shop_bookings ADD COLUMN choices TEXT NOT NULL DEFAULT '{}';
12
+ ${['INSERT','UPDATE OF state,allocation'].map((op,i)=>`CREATE TRIGGER booking_resource_${i} BEFORE ${op} ON shop_bookings WHEN NEW.state IN ('held','confirmed') BEGIN ${capacityCheck} END;`).join('\n')}
13
+ CREATE TRIGGER booking_resource_move BEFORE UPDATE OF slot,quantity ON shop_bookings WHEN (OLD.slot!=NEW.slot OR OLD.quantity!=NEW.quantity) AND OLD.allocation!='[]' AND OLD.state IN ('held','confirmed') BEGIN SELECT RAISE(ABORT,'resource booking move requires release and a new allocation'); END;
14
+ CREATE TRIGGER booking_resource_edit BEFORE UPDATE OF value ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) AND NEW.value!=OLD.value BEGIN SELECT RAISE(ABORT,'resource has active bookings; release or finish them before editing'); END;
15
+ CREATE TRIGGER booking_resource_delete BEFORE DELETE ON app_records WHEN EXISTS(SELECT 1 FROM shop_bookings b,json_each(b.allocation) a WHERE b.app=OLD.app AND b.state IN ('held','confirmed') AND json_extract(a.value,'$.resource')=OLD.id AND json_extract(a.value,'$.collection')=OLD.collection) BEGIN SELECT RAISE(ABORT,'resource has active bookings'); END;
16
+ `;
17
+ export async function allocateResources(env,app,spec,slot,args) {
18
+ const row=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.slots,slot).first();
19
+ if(!row)throw new Error('Slot unavailable');
20
+ const data=JSON.parse(row.value),option=args.option===undefined?null:data.options?.find(o=>o.id===args.option);
21
+ if(args.option!==undefined&&!option)throw new Error('Choose a declared option');
22
+ if(data.requireOption&&!option)throw new Error('Choose an option');
23
+ const parts=option?.components??(data.resources?[{resources:data.resources,skills:data.skills,attributes:data.attributes}]:[]);
24
+ const components=[...parts];
25
+ for(const [id,count] of Object.entries(args.addons??{})) {
26
+ const addon=data.addons?.find(a=>a.id===id);if(!addon)throw new Error('Unknown add-on');
27
+ for(const component of addon.components??[])components.push({...component,units:(component.units??1)*count});
28
+ }
29
+ if(!components.length){if(args.resource)throw new Error('This slot has no declared resources');return [];}
30
+ if(!spec.resources)throw new Error('Declare booking.resources collection');
31
+ const start=Date.parse(data.start),end=Date.parse(data.end);
32
+ if(!Number.isFinite(start)||!Number.isFinite(end)||end<=start)throw new Error('Resource slot needs start and end timestamps');
33
+ if(components.length>32)throw new Error('A package supports at most 32 resource components');
34
+ const active=(await env.DB.prepare("SELECT allocation FROM shop_bookings WHERE app=?1 AND state IN ('held','confirmed')").bind(app).all()).results.flatMap(r=>JSON.parse(r.allocation));
35
+ const allocations=[];
36
+ for(const [index,c] of components.entries()) {
37
+ if(!Array.isArray(c.resources)||!c.resources.length||c.resources.length>64)throw new Error('Declare candidate resources');
38
+ const candidates=index===0&&args.resource?[args.resource]:c.resources;
39
+ if(candidates.some(id=>!c.resources.includes(id)))throw new Error('Preferred resource is unavailable for this option');
40
+ const from=start+(c.offsetMinutes??0)*60000,to=c.durationMinutes?from+c.durationMinutes*60000:end,units=(c.units??1)*(args.quantity??1);
41
+ if(!Number.isSafeInteger(from)||!Number.isSafeInteger(to)||from<start||to>end||to<=from||!Number.isSafeInteger(units)||units<1)throw new Error('Invalid package resource interval or units');
42
+ let selected,qualified=false;
43
+ for(const resource of candidates) {
44
+ const record=await env.DB.prepare('SELECT value FROM app_records WHERE app=?1 AND collection=?2 AND id=?3').bind(app,spec.resources,resource).first();
45
+ if(!record)continue;const r=JSON.parse(record.value);
46
+ if(!Number.isSafeInteger(r.capacity)||r.capacity<1||(c.skills??[]).some(skill=>!r.skills?.includes(skill))||Object.entries(c.attributes??{}).some(([k,v])=>r.attributes?.[k]!==v))continue;
47
+ qualified=true;const overlapping=[...active,...allocations].filter(a=>a.resource===resource&&a.collection===spec.resources&&a.start<to&&a.end>from);
48
+ if([from,...overlapping.map(a=>a.start).filter(t=>t>=from)].some(at=>units+overlapping.filter(a=>a.start<=at&&a.end>at).reduce((n,a)=>n+a.units,0)>r.capacity))continue;
49
+ selected={resource,collection:spec.resources,start:from,end:to,units};break;
50
+ }
51
+ if(!selected)throw new Error(qualified?'Resource capacity unavailable for these dates':'No resource matches the required skills or attributes');
52
+ allocations.push(selected);
53
+ }
54
+ return allocations;
55
+ }
@@ -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.52.0","sha256":"f2d43374183c3a1d807c67754b0b9e868b18d5f6b137e0fd5f224fd6e5866e17"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.53.0","sha256":"c43b8fcfbd61ff241c86555b2806cd599baf71513376d3958c379b04697dd923"});
@@ -26,7 +26,7 @@ export function customerTools(env,cat,origin) {
26
26
  return [
27
27
  {name:'studio_catalogue',description:'Browse the public shop, paid parts and app goods. Prices use the studio’s currency minor units.',audience:'public',inputSchema:object(),handler:async(_args,ctx)=>({shop:await customerShop(env,cat,origin,ctx.caller,'/api/shop'),resources:await listOffers(env,origin)})},
28
28
  {name:'studio_cart',description:'Quote a cart for agent payment. Retain the returned private account cookies to use guest purchases. Approve this immutable offerVersion before purchase.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>quoteCart(env,cat,origin,ctx.caller,args,ctx.request)},
29
- {name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:cartSchema,handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
29
+ {name:'studio_checkout',description:'Open the studio’s Stripe checkout for a person to approve. Uses the same cart, account, order and refund as the website.',audience:'public',inputSchema:{...cartSchema,properties:{...cartSchema.properties,reference:str,booking:object({collection:str,slot:str,hold:str,token:str},['collection','slot'])}},handler:(args,ctx)=>customerShop(env,cat,origin,ctx.caller,'/api/shop/buy',args)},
30
30
  {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
31
  {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
32
  {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();}},