@homie-rocks/studio 0.55.0 → 0.57.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 (72) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +2 -0
  3. package/apps/APPS.md +9 -0
  4. package/apps/apps.ts +19 -1
  5. package/bin/homie-studio.mjs +1 -1
  6. package/docs/custom-routes.md +72 -0
  7. package/docs/studio-to-studio.md +248 -0
  8. package/functions/FUNCTIONS.md +9 -0
  9. package/guides/app/SKILL.md +7 -0
  10. package/guides/playtest/scripts/playtest.mjs +7 -5
  11. package/lib/check.mjs +8 -1
  12. package/lib/cloudflare.mjs +52 -22
  13. package/lib/dev.mjs +6 -8
  14. package/lib/local-database-start.mjs +6 -0
  15. package/lib/mcp-tools.mjs +3 -3
  16. package/lib/needs-headless.mjs +1 -1
  17. package/lib/port-check.mjs +3 -2
  18. package/lib/scaffold.mjs +2 -0
  19. package/lib/studios-keys.mjs +13 -0
  20. package/lib/template-history.json +88 -1
  21. package/lib/worker-build.mjs +4 -1
  22. package/lib/worker-config.mjs +7 -0
  23. package/netplay/NETPLAY.md +12 -1
  24. package/netplay/netplay.ts +24 -9
  25. package/package.json +1 -1
  26. package/rules/core.ts +9 -0
  27. package/rules/host.ts +40 -11
  28. package/rules/view.ts +1 -0
  29. package/tools/TOOLS.md +22 -1
  30. package/tools/tools.ts +14 -1
  31. package/worker/background-queue.mjs +1 -0
  32. package/worker/booking-availability.mjs +35 -0
  33. package/worker/booking-holds.mjs +2 -2
  34. package/worker/booking-resources.mjs +14 -6
  35. package/worker/build-id.mjs +1 -1
  36. package/worker/business-events.mjs +2 -1
  37. package/worker/custom-routes.mjs +16 -0
  38. package/worker/gate.mjs +94 -23
  39. package/worker/idle-chat.mjs +56 -0
  40. package/worker/index.mjs +2 -0
  41. package/worker/lazy-objects.mjs +3 -0
  42. package/worker/lobby-peak.mjs +44 -0
  43. package/worker/lobby-shards.mjs +43 -0
  44. package/worker/lobby-store.mjs +35 -0
  45. package/worker/lobby-summary.mjs +28 -0
  46. package/worker/lobby.mjs +86 -18
  47. package/worker/lounge-store.mjs +2 -2
  48. package/worker/match-router.mjs +74 -0
  49. package/worker/mcp-room.mjs +2 -1
  50. package/worker/mcp-store.mjs +3 -1
  51. package/worker/mcp-tools.mjs +29 -4
  52. package/worker/mcp.mjs +43 -8
  53. package/worker/office.mjs +2 -1
  54. package/worker/operational-schema.mjs +2 -0
  55. package/worker/pages.mjs +1 -1
  56. package/worker/relay-channel.mjs +119 -0
  57. package/worker/request-db.mjs +4 -3
  58. package/worker/room-chat.mjs +2 -1
  59. package/worker/room-sessions.mjs +65 -0
  60. package/worker/room.mjs +45 -9
  61. package/worker/route-auth.mjs +22 -0
  62. package/worker/routes.mjs +8 -5
  63. package/worker/runtime-support.mjs +5 -3
  64. package/worker/session-codec.mjs +24 -0
  65. package/worker/stats.mjs +2 -1
  66. package/worker/studios-auth.mjs +71 -0
  67. package/worker/studios-client.mjs +93 -0
  68. package/worker/studios-events.mjs +52 -0
  69. package/worker/studios-keys.mjs +54 -0
  70. package/worker/table.mjs +277 -41
  71. package/worker/version.mjs +1 -1
  72. package/worker/watch-sockets.mjs +75 -0
package/rules/host.ts CHANGED
@@ -87,6 +87,8 @@ export interface HostOptions {
87
87
  restoreEpoch?: number;
88
88
  startDelayMs?: number;
89
89
  onTick?(): void;
90
+ /** Suspend settled round breaks; the owner persists and wakes this deadline. */
91
+ onRoundIdle?(until: number): void;
90
92
  /** Only the shared server isolate can attribute timer lateness to another tick. */
91
93
  enforceOverrun?: boolean;
92
94
  log?(line: Record<string, unknown>): void;
@@ -111,6 +113,8 @@ export interface Host {
111
113
  readonly epoch: number;
112
114
  readonly running: boolean;
113
115
  readonly paused: boolean;
116
+ readonly roundIdleUntil: number | null;
117
+ wakeRound(): void;
114
118
  readonly people: number;
115
119
  readonly viewRadiusM: number | null;
116
120
  readonly privateDelivery: boolean;
@@ -199,13 +203,14 @@ export function createHost(o: HostOptions): Host {
199
203
  // A log that throws takes nothing down with it.
200
204
  const log = (line: Record<string, unknown>): void => { try { o.log?.(line); } catch { /* nothing to say it with */ } };
201
205
  const random = o.random ?? Math.random;
202
- const saved = o.restore ? fromBytes(o.restore) as { v: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
206
+ const saved = o.restore ? fromBytes(o.restore) as { v: number; roundIdleUntil?: number; characters?: CharacterLease[]; core: SavedCore; names: [number, string][]; agents?: AgentsSaved; guideViews?: [number, { id: string; at: number; value: Record<string, unknown> }][]; queues: [number, Record<string, unknown>, StepInput['claim'], number, number][]; inputs?: [number, SeatIn][] } : null;
203
207
  if (o.restore && (!saved || typeof saved !== 'object' || Array.isArray(saved))) throw new Error('saved host is not an object');
204
208
  if (saved) {
205
209
  const check = (ok: unknown): void => { if (!ok) throw new Error('saved host inputs are invalid'); };
206
210
  const uint = (n: unknown): boolean => Number.isSafeInteger(n) && (n as number) >= 0;
207
- check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters'].includes(k)));
211
+ check(Object.keys(saved).every(k => ['v', 'core', 'names', 'agents', 'guideViews', 'queues', 'inputs', 'characters', 'roundIdleUntil'].includes(k)));
208
212
  check(saved.core && Array.isArray(saved.names) && Array.isArray(saved.queues));
213
+ if (saved.roundIdleUntil !== undefined) check(Number.isFinite(saved.roundIdleUntil) && saved.roundIdleUntil >= 0 && saved.core.round[1] === 0 && !saved.core.overAt);
209
214
  if (saved.characters !== undefined) {
210
215
  check(c.settings.characters && Array.isArray(saved.characters));
211
216
  check(new Set(saved.characters.map(c => c.account)).size === saved.characters.length);
@@ -396,6 +401,7 @@ export function createHost(o: HostOptions): Host {
396
401
  core.goal(b.seat, g?.state === 'active' ? { goal: g.goal, args: g.args, from: g.from, at: Math.round(g.at / period), asked: g.asked === true } : null);
397
402
  }
398
403
  }
404
+ let roundIdleUntil: number | null = o.onRoundIdle && Number.isFinite(saved?.roundIdleUntil) ? saved!.roundIdleUntil! : null;
399
405
  let running = false;
400
406
  let paused = Boolean(saved && (o.startPaused ?? o.restoreEpoch !== undefined));
401
407
  let ended = false;
@@ -594,7 +600,7 @@ export function createHost(o: HostOptions): Host {
594
600
  core.seatLeave(held.seat);
595
601
  tick(o.clock.now());
596
602
  } else core.seatAway(m.seat as number, true);
597
- if (running && people() === 0) pause();
603
+ if ((running || roundIdleUntil !== null) && people() === 0) pause();
598
604
  return;
599
605
  }
600
606
  case 'free': {
@@ -832,7 +838,17 @@ export function createHost(o: HostOptions): Host {
832
838
  }
833
839
  timing.record('tick', tickAt);
834
840
  if (ended) return;
835
- if (!faulted && core.stats.ticksCut === cut) { failingSince = null; return; }
841
+ if (!faulted && core.stats.ticksCut === cut) {
842
+ failingSince = null;
843
+ const round = core.round();
844
+ if (o.onRoundIdle && running && round.phase === 'over' && round.results && round.endsAt > core.tick) {
845
+ roundIdleUntil = now + (round.endsAt - core.tick) * period;
846
+ running = false; disarm();
847
+ try { o.store?.save(save()); o.onRoundIdle(roundIdleUntil); }
848
+ catch (error) { fault('round-idle', error); stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); }
849
+ }
850
+ return;
851
+ }
836
852
  if (failingSince === null) failingSince = now;
837
853
  if (now - failingSince < FAIL_MS) return;
838
854
  if (faulted) end('fault', { error: stats.lastFault, faults: stats.faults });
@@ -913,7 +929,7 @@ export function createHost(o: HostOptions): Host {
913
929
  const self = { blame: (): void => { blamed = true; } };
914
930
 
915
931
  function start(): void {
916
- if (running || ended) return;
932
+ if (running || ended || roundIdleUntil !== null) return;
917
933
  running = true; paused = false;
918
934
  try {
919
935
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
@@ -926,26 +942,37 @@ export function createHost(o: HostOptions): Host {
926
942
  } catch (error) { fault('start', error); } finally { keepTime(); }
927
943
  }
928
944
  function pause(): void {
929
- if (!running) return;
945
+ if (!running) {
946
+ if (roundIdleUntil !== null && !paused) { paused = true; checkpoint(true); o.onPause?.(); }
947
+ return;
948
+ }
930
949
  running = false; paused = true;
931
950
  // A pending timer would keep the object in memory and billed: the pause clears it.
932
951
  disarm();
933
952
  log({ ev: 'host-pause', game: o.game, tick: core.tick });
934
953
  try { checkpoint(true); o.onPause?.(); } catch (error) { fault('onPause', error); if (persistent) { stop(); o.onEnd?.('storage-or-tick-failed', { tick: core.tick }); } }
935
954
  }
936
- function resume(): void {
937
- if (running || ended || !paused) return;
955
+ function resume(announceRound = true): void {
956
+ if (running || ended || !paused || roundIdleUntil !== null) return;
938
957
  running = true; paused = false;
939
958
  try {
940
959
  // The room resumes at the tick it paused on: no tick is skipped and no timer fires for the gap.
941
960
  base = { ms: o.clock.now() + (firstTick ? o.startDelayMs ?? 0 : 0), tick: core.tick };
942
961
  overSince = null; blamed = false; failingSince = null; lastStart = base.ms;
943
- handle([core.round()], base.ms);
962
+ if (announceRound) handle([core.round()], base.ms);
944
963
  for (const q of queues.values()) q.lastFrameTick = core.tick;
945
964
  log({ ev: 'host-resume', game: o.game, tick: core.tick });
946
965
  o.onResume?.();
947
966
  } catch (error) { fault('resume', error); } finally { keepTime(); }
948
967
  }
968
+ function wakeRound(): void {
969
+ if (roundIdleUntil === null || o.clock.now() < roundIdleUntil || ended) return;
970
+ if (!core.resumeRound()) throw new Error('Round is not settled');
971
+ roundIdleUntil = null;
972
+ handle(core.drain(), o.clock.now());
973
+ if (people() > 0) { paused = true; resume(false); }
974
+ else { paused = true; checkpoint(true); }
975
+ }
949
976
  /** What a relay keeps for whoever joins next, said again: the round with its results, the roster, the shared state, the capabilities. */
950
977
  function announce(): void {
951
978
  if (ended) return;
@@ -975,7 +1002,7 @@ export function createHost(o: HostOptions): Host {
975
1002
 
976
1003
  function save(): Uint8Array {
977
1004
  const reserved = new Set(core.bodies().filter(b => b.owner === 'reserved').map(b => b.seat));
978
- return toBytes({ v: 1, ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
1005
+ return toBytes({ v: 1, ...(roundIdleUntil !== null ? { roundIdleUntil } : {}), ...(characters.size ? { characters: [...characters.values()] } : {}), core: core.save(), guideViews: [...guideViews], ...(agents ? { agents: agents.save() } : savedAgents ? { agents: savedAgents } : {}), names: [...names].filter(([seat]) => !reserved.has(seat)), queues: [...queues].map(([seat, q]) => [seat, q.held, q.claim, q.newest, q.ack]), inputs: [...queues] });
979
1006
  }
980
1007
  function checkpoint(force = false): void {
981
1008
  if (persistent) {
@@ -1022,7 +1049,7 @@ export function createHost(o: HostOptions): Host {
1022
1049
  held.character = core.character(held.seat) ?? held.character; held.returning = true;
1023
1050
  present.delete(held.seat); queues.delete(held.seat); names.delete(held.seat);
1024
1051
  if (held.seat >= 0) core.seatLeave(held.seat); tick(o.clock.now());
1025
- if (running && people() === 0) pause();
1052
+ if ((running || roundIdleUntil !== null) && people() === 0) pause();
1026
1053
  },
1027
1054
  characterReturned(account, lease) { if (characters.get(account)?.returning && characters.get(account)?.lease === lease) characters.delete(account); },
1028
1055
  get viewSettings() { return c.settings.view; },
@@ -1032,6 +1059,8 @@ export function createHost(o: HostOptions): Host {
1032
1059
  get epoch() { return epoch; },
1033
1060
  get running() { return running; },
1034
1061
  get paused() { return paused; },
1062
+ get roundIdleUntil() { return roundIdleUntil; },
1063
+ wakeRound,
1035
1064
  get people() { return people(); },
1036
1065
  frame, start, pause, resume, announce,
1037
1066
  // An orderly stop of a lasting world folds what moved since the last checkpoint. One that follows a failed write, or arrives during one, writes nothing.
package/rules/view.ts CHANGED
@@ -941,6 +941,7 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
941
941
  net.on('snapshot', onSnapshot);
942
942
  net.on('role', (e) => { if (e.snap) onSnapshot(e.snap); if (net.offline && !net.rulesHosting) setStatus(net.closedWhy ? 'closed' : 'offline'); });
943
943
  net.on('link', (e) => {
944
+ if (e.why === 'subscribe') return;
944
945
  if (e.state === 'online') { returningUntil = clock() + 3000; base = null; if (status !== 'playing') setStatus('connecting'); } else if (e.state === 'closed') setStatus('closed'); else if (e.state === 'alone' || e.state === 'offline') setStatus('offline');
945
946
  });
946
947
  net.on('round', (r) => { if (r.results) { results = r.results; if (round) { round = { ...round, results }; emit('round', roundNow()); } } });
package/tools/TOOLS.md CHANGED
@@ -53,7 +53,12 @@ Workers AI requires the studio's own binding and owner authority, or an explicit
53
53
  never exposes the environment or raw credentials. A named secret capability in
54
54
  `studio.json` (`mcp.secrets.NAME: { origin, header, prefix, tools }`) lets an owner tool
55
55
  make an HTTPS request to one declared origin; `tools` can explicitly delegate this capability to named staff tools; redirects and credential echoes are
56
- refused. The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
56
+ refused. Use `origin: 'self'` for the studio's own API: the capability resolves to
57
+ the current Worker request origin (or the configured studio origin for scheduled
58
+ functions), so a workers.dev address need not be known at build time. Calls may
59
+ use a relative URL such as `ctx.fetchWithSecret('API_TOKEN', '/api/sync')`.
60
+ HTTPS is still required; credentials in URLs and cross-origin targets are refused.
61
+ The studio’s AI can generate an integration secret and place it in Cloudflare and the sender through their authorized APIs. Never ask the person to find or paste a key, and never put a secret in source files.
57
62
 
58
63
  Studio handlers and imported parts are trusted Worker code, not sandboxed plugins.
59
64
  Review shared code before adding it: JavaScript wrappers cannot confine malicious
@@ -188,3 +193,19 @@ Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
188
193
  Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
189
194
  token. An approved connection retains the account’s current app grants, not a copy
190
195
  of its old permissions. Manage connections at `/_studio/office/connections`.
196
+
197
+ ## Acting at another studio
198
+
199
+ Use `context.studios.listTools`, `call` and `send` for another studio. The official
200
+ MCP SDK handles transport and OAuth. Configure receiver-owned studio allowlists;
201
+ a named person's handle is an assertion, not the receiver's authenticated account.
202
+ Discover `studio_availability` before `studio_book`. For your own registered routes,
203
+ use `fetchSelf` to preserve the caller. Opt-in `rateLimit` shares a declared
204
+ Cloudflare binding between tools. See [studio-to-studio](../docs/studio-to-studio.md)
205
+ for configuration, trust boundaries, signed events and examples.
206
+
207
+ Studio callers use origin-hosted JWKS and private-key JWT; office Allow/Deny is
208
+ stored in D1. Pass `{assertedHandle:'guest-42'}`; never match this assertion to a
209
+ local account. Results and descriptions carry `{source,untrusted:true}`. Wrap them
210
+ as untrusted input before AI use. Anonymous MCP callers expose `clientAddressKey`,
211
+ which is their default rate-limit key; studio callers share `studio:<origin>`.
package/tools/tools.ts CHANGED
@@ -1,12 +1,21 @@
1
+ import type {BookingAvailability} from '../apps/apps';
1
2
  /** A studio tool runs as trusted code in its own Worker. Never import an environment binding. */
2
3
  export type Audience = 'public' | 'signed-in' | 'owner' | { app: string; role: string };
4
+ export interface RemoteStudioSource { readonly origin: string; readonly authenticated: boolean }
5
+ export interface UntrustedStudioData { readonly source: RemoteStudioSource; readonly untrusted: true }
3
6
  export interface ToolContext {
4
7
  readonly purchase?: { readonly id: string };
5
- readonly caller: { readonly id: string | null; readonly owner: boolean; readonly client: string | null; readonly roles: Readonly<Record<string, readonly string[]>> };
8
+ readonly caller: { readonly clientAddressKey?: string; readonly id: string | null; readonly owner: boolean; readonly client: string | null; readonly studio?: {readonly origin: string; readonly verified: true; readonly method: 'private_key_jwt'}; readonly actingFor?: {readonly assertedHandle: string; readonly verified: false; readonly assertedBy: string}; readonly roles: Readonly<Record<string, readonly string[]>> };
6
9
  records(args: { app: string; role?: string; collection: string; id?: string; operation?: 'read' | 'create' | 'update' | 'delete'; data?: Record<string, unknown>; version?: number }): Promise<unknown>;
7
10
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
11
  shop(): Promise<unknown>;
12
+ studios: {
13
+ send(origin: string, event: string, input: {id: string; data: unknown}): Promise<{id: string}>;
14
+ listTools(origin: string, options?: {cursor?: string; assertedHandle?: string}): Promise<UntrustedStudioData & {tools: Array<UntrustedStudioData & {name: string; description?: string; inputSchema: Record<string, unknown>}>; nextCursor?: string}>;
15
+ call(origin: string, tool: string, args?: Record<string, unknown>, options?: {assertedHandle?: string}): Promise<UntrustedStudioData & {isError: boolean; data?: unknown; content: unknown[]}>;
16
+ };
9
17
  bookings: {
18
+ availability(args: {app: string; collection: string; after?: string; quantity?: number; option?: string; resource?: string; addons?: Record<string,number>}): Promise<BookingAvailability>;
10
19
  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
20
  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>;
12
21
  get(args: {id: string; token?: string}): Promise<BookingHold>;
@@ -24,6 +33,10 @@ export interface ToolContext {
24
33
  append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
25
34
  list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
26
35
  };
36
+ /** Dispatch a custom route with caller identity and local Origin; routes must still authorize. */
37
+ fetchSelf(path: string, init?: RequestInit): Promise<Response>;
38
+ /** Opt-in shared Cloudflare Rate Limiting binding. Defaults to studio origin, signed-in identity, or anonymous MCP clientAddressKey. */
39
+ rateLimit(name: string, key?: string): Promise<{success: boolean}>;
27
40
  fetch: typeof fetch;
28
41
  fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
29
42
  ai(model: string, input: unknown): Promise<unknown>;
@@ -22,6 +22,7 @@ export async function consumeBackground(batch, env) {
22
22
  }
23
23
  for(const message of batch.messages)if(!changes.includes(message)) {
24
24
  try {
25
+ if(message.body?.v===1&&message.body.type==='lobby.snapshot'){await (await import('./lobby-summary.mjs')).storeLobbySummary(env,message.body);message.ack();continue;}
25
26
  if(message.body?.v!==1||message.body.type!=='stripe.event')throw new Error('Unknown background message type');
26
27
  await (await import('./stripe-intake.mjs')).reconcileStripeEvent(env,message.body.id,await catalogue(env));
27
28
  message.ack();
@@ -0,0 +1,35 @@
1
+ import {allocateResources,resourceSnapshot} from './booking-resources.mjs';
2
+ import {settingsOf,launchOf} from './office.mjs';
3
+ import {allowed} from './mcp-tools.mjs';
4
+ const fail=message=>Object.assign(new Error(message),{name:'StudioOperationError'});
5
+ /** A snapshot, never a reservation. Atomic holds recheck capacity at write time. */
6
+ export async function bookingAvailability(env,cat,caller,{app,collection,after='',quantity=1,option,resource,addons}={}) {
7
+ const meta=cat.games.find(g=>g.id===app&&g.kind==='app'),spec=meta?.records?.collections?.[collection]?.booking;
8
+ if(!spec||typeof after!=='string'||after.length>64||!Number.isSafeInteger(quantity)||quantity<1)throw fail('Booking availability unavailable');
9
+ if(!caller.owner){
10
+ if(launchOf(meta,await settingsOf(env,{fresh:true}),env)!=='public')throw fail('Booking availability unavailable');
11
+ let readable=false;
12
+ for(const [role,rule] of Object.entries(meta.roles??{}))if(rule.can?.includes('read:'+spec.slots)&&(!rule.signIn||await allowed({app,role},caller,env)))readable=true;
13
+ if(!readable)throw fail('Booking availability unavailable');
14
+ }
15
+ const scope=caller.testScope?`${app}:check:${caller.testScope}`:app;
16
+ const rows=(await env.DB.prepare(`SELECT s.id,s.value,COALESCE((SELECT SUM(b.quantity) FROM shop_bookings b WHERE b.app=s.app AND b.slots=s.collection AND b.slot=s.id AND b.state IN ('held','confirmed')),0) used FROM app_records s WHERE s.app=?1 AND s.collection=?2 AND s.id>?3 ORDER BY s.id LIMIT 51`).bind(scope,spec.slots,after).all()).results;
17
+ const snapshot=await resourceSnapshot(env,scope,spec);
18
+ const items=[];
19
+ for(const row of rows.slice(0,50)){
20
+ const slot=JSON.parse(row.value),remaining=slot.capacity-row.used;
21
+ if(!Number.isSafeInteger(remaining)||remaining<quantity)continue;
22
+ const choices=option!==undefined?[option]:[...(!slot.requireOption?[undefined]:[]),...(slot.options??[]).map(o=>o.id)];
23
+ if(choices.length>64)throw fail('Availability supports at most 64 options per slot');
24
+ for(const choice of choices){
25
+ const booking={app,collection,slot:row.id,quantity,...(choice===undefined?{}:{option:choice}),...(resource?{resource}:{}),...(addons?{addons}:{})};
26
+ try{allocateResources(slot,spec,booking,snapshot);}catch(error){
27
+ if(/Slot unavailable|Choose a declared option|Choose an option|Unknown add-on|no declared resources|Preferred resource|Resource capacity unavailable|No resource matches/.test(error.message))continue;
28
+ throw error;
29
+ }
30
+ const selected=slot.options?.find(o=>o.id===choice);
31
+ items.push({'@type':'Offer',identifier:JSON.stringify([row.id,choice??null]),...(selected?.name?{name:selected.name}:{}),availability:'https://schema.org/InStock',...(slot.start?{availabilityStarts:slot.start}:{}),...(slot.end?{availabilityEnds:slot.end}:{}),remaining,booking});
32
+ }
33
+ }
34
+ return {convention:'studio.booking-availability/1',bookingTool:'studio_book',items,next:rows.length>50?rows[49].id:null};
35
+ }
@@ -1,5 +1,5 @@
1
1
  import {calendarSlots} from './booking-calendar.mjs';
2
- import {allocateResources} from './booking-resources.mjs';
2
+ 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';
@@ -74,7 +74,7 @@ export function bookingHolds(env,caller,cat){
74
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
75
  return result(previous);
76
76
  }
77
- let allocation;try{allocation=await allocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
77
+ let allocation;try{allocation=await loadAndAllocateResources(env,scope,spec,slot,args);}catch(error){throw refusal(error.message,409);}
78
78
  const secret=token?await hash(token):null;
79
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
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;}
@@ -14,10 +14,20 @@ CREATE TRIGGER booking_resource_move BEFORE UPDATE OF slot,quantity ON shop_book
14
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
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
16
  `;
17
- export async function allocateResources(env,app,spec,slot,args) {
17
+ export async function resourceSnapshot(env,app,spec) {
18
+ if(!spec.resources)return {resources:new Map(),active:[]};
19
+ const resources=(await env.DB.prepare('SELECT id,value FROM app_records WHERE app=?1 AND collection=?2').bind(app,spec.resources).all()).results;
20
+ 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));
21
+ return {resources:new Map(resources.map(r=>[r.id,JSON.parse(r.value)])),active};
22
+ }
23
+ export async function loadAndAllocateResources(env,app,spec,slot,args) {
18
24
  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);
25
+ return allocateResources(row?JSON.parse(row.value):null,spec,args,await resourceSnapshot(env,app,spec));
26
+ }
27
+ /** Pure allocation against a page snapshot. Holds still enforce capacity atomically in D1. */
28
+ export function allocateResources(data,spec,args,{resources,active}) {
29
+ if(!data)throw new Error('Slot unavailable');
30
+ const option=args.option===undefined?null:data.options?.find(o=>o.id===args.option);
21
31
  if(args.option!==undefined&&!option)throw new Error('Choose a declared option');
22
32
  if(data.requireOption&&!option)throw new Error('Choose an option');
23
33
  const parts=option?.components??(data.resources?[{resources:data.resources,skills:data.skills,attributes:data.attributes}]:[]);
@@ -31,7 +41,6 @@ export async function allocateResources(env,app,spec,slot,args) {
31
41
  const start=Date.parse(data.start),end=Date.parse(data.end);
32
42
  if(!Number.isFinite(start)||!Number.isFinite(end)||end<=start)throw new Error('Resource slot needs start and end timestamps');
33
43
  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
44
  const allocations=[];
36
45
  for(const [index,c] of components.entries()) {
37
46
  if(!Array.isArray(c.resources)||!c.resources.length||c.resources.length>64)throw new Error('Declare candidate resources');
@@ -41,8 +50,7 @@ export async function allocateResources(env,app,spec,slot,args) {
41
50
  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
51
  let selected,qualified=false;
43
52
  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);
53
+ const r=resources.get(resource);if(!r)continue;
46
54
  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
55
  qualified=true;const overlapping=[...active,...allocations].filter(a=>a.resource===resource&&a.collection===spec.resources&&a.start<to&&a.end>from);
48
56
  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;
@@ -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.55.0","sha256":"597d65592bde7e9519b8184bc2db2502a88ef37cfc31dd6ba3b1c9a4ece30132"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.57.0","sha256":"0a84d357448b7257ee97cac637957a6fe5bb46323de76625644d88e80a684a87"});
@@ -1,4 +1,5 @@
1
1
  import {canonicalJson} from './canonical-json.mjs';
2
+ export const BUSINESS_EVENT_MAX_BYTES=8192;
2
3
  export const BUSINESS_EVENTS_MIGRATION_FILE='0028_business_events.sql';
3
4
  export const BUSINESS_EVENTS_MIGRATION=`
4
5
  ALTER TABLE studio_events ADD COLUMN scope TEXT;
@@ -19,7 +20,7 @@ export async function publishBusinessEvent(env,caller,definition,namespace,{feed
19
20
  if(typeof id!=='string'||!id.length||id.length>128)throw new Error('Event id must contain 1–128 characters');
20
21
  const {CfWorkerJsonSchemaValidator}=await import('@modelcontextprotocol/server/validators/cf-worker');
21
22
  if(!new CfWorkerJsonSchemaValidator().getValidator(declaration.schema)(data).valid)throw new Error('Event data does not match feed schema');
22
- const text=canonicalJson(data);if(text.length>8192)throw new Error('Event exceeds 8192 characters');
23
+ const text=canonicalJson(data);if(new TextEncoder().encode(text).length>BUSINESS_EVENT_MAX_BYTES)throw new Error('Event exceeds 8192 bytes');
23
24
  const key=JSON.stringify([namespace,feed,id]),digest=Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(declaration.event+'\n'+text))),b=>b.toString(16).padStart(2,'0')).join('');
24
25
  await env.DB.batch([
25
26
  env.DB.prepare('INSERT OR IGNORE INTO studio_events(id,type,data,at,scope) SELECT ?1,?2,?3,?4,?5 WHERE NOT EXISTS (SELECT 1 FROM event_publications WHERE id=?1)').bind(key,declaration.event,text,Date.now(),caller.testScope??null),
@@ -0,0 +1,16 @@
1
+ /** Studio configuration only: register once at module evaluation, never per request. */
2
+ let handler = null;
3
+ export function useRoutes(routes) {
4
+ if (routes !== null && typeof routes !== 'function') throw new TypeError('useRoutes expects a function or null');
5
+ handler = routes;
6
+ }
7
+ export async function customRoute(request, env, ctx) {
8
+ const response = await handler?.(request, env, ctx);
9
+ if (response == null) return null;
10
+ if (!(response instanceof Response)) throw new TypeError('A custom route must return a Response, null, or undefined');
11
+ if (response.status === 101 || response.webSocket) return response;
12
+ const headers = new Headers(response.headers);
13
+ headers.set('x-content-type-options', 'nosniff');
14
+ if (!headers.has('referrer-policy')) headers.set('referrer-policy', 'strict-origin-when-cross-origin');
15
+ return new Response(response.body, {status:response.status,statusText:response.statusText,headers});
16
+ }