@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.
- package/CHANGELOG.md +72 -0
- package/README.md +2 -0
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/bin/homie-studio.mjs +1 -1
- package/docs/custom-routes.md +72 -0
- package/docs/studio-to-studio.md +248 -0
- package/functions/FUNCTIONS.md +9 -0
- package/guides/app/SKILL.md +7 -0
- package/guides/playtest/scripts/playtest.mjs +7 -5
- package/lib/check.mjs +8 -1
- package/lib/cloudflare.mjs +52 -22
- package/lib/dev.mjs +6 -8
- package/lib/local-database-start.mjs +6 -0
- package/lib/mcp-tools.mjs +3 -3
- package/lib/needs-headless.mjs +1 -1
- package/lib/port-check.mjs +3 -2
- package/lib/scaffold.mjs +2 -0
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +88 -1
- package/lib/worker-build.mjs +4 -1
- package/lib/worker-config.mjs +7 -0
- package/netplay/NETPLAY.md +12 -1
- package/netplay/netplay.ts +24 -9
- package/package.json +1 -1
- package/rules/core.ts +9 -0
- package/rules/host.ts +40 -11
- package/rules/view.ts +1 -0
- package/tools/TOOLS.md +22 -1
- package/tools/tools.ts +14 -1
- package/worker/background-queue.mjs +1 -0
- package/worker/booking-availability.mjs +35 -0
- package/worker/booking-holds.mjs +2 -2
- package/worker/booking-resources.mjs +14 -6
- package/worker/build-id.mjs +1 -1
- package/worker/business-events.mjs +2 -1
- package/worker/custom-routes.mjs +16 -0
- package/worker/gate.mjs +94 -23
- package/worker/idle-chat.mjs +56 -0
- package/worker/index.mjs +2 -0
- package/worker/lazy-objects.mjs +3 -0
- package/worker/lobby-peak.mjs +44 -0
- package/worker/lobby-shards.mjs +43 -0
- package/worker/lobby-store.mjs +35 -0
- package/worker/lobby-summary.mjs +28 -0
- package/worker/lobby.mjs +86 -18
- package/worker/lounge-store.mjs +2 -2
- package/worker/match-router.mjs +74 -0
- package/worker/mcp-room.mjs +2 -1
- package/worker/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +29 -4
- package/worker/mcp.mjs +43 -8
- package/worker/office.mjs +2 -1
- package/worker/operational-schema.mjs +2 -0
- package/worker/pages.mjs +1 -1
- package/worker/relay-channel.mjs +119 -0
- package/worker/request-db.mjs +4 -3
- package/worker/room-chat.mjs +2 -1
- package/worker/room-sessions.mjs +65 -0
- package/worker/room.mjs +45 -9
- package/worker/route-auth.mjs +22 -0
- package/worker/routes.mjs +8 -5
- package/worker/runtime-support.mjs +5 -3
- package/worker/session-codec.mjs +24 -0
- package/worker/stats.mjs +2 -1
- package/worker/studios-auth.mjs +71 -0
- package/worker/studios-client.mjs +93 -0
- package/worker/studios-events.mjs +52 -0
- package/worker/studios-keys.mjs +54 -0
- package/worker/table.mjs +277 -41
- package/worker/version.mjs +1 -1
- 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) {
|
|
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)
|
|
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.
|
|
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
|
+
}
|
package/worker/booking-holds.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import {calendarSlots} from './booking-calendar.mjs';
|
|
2
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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;
|
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.
|
|
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>
|
|
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
|
+
}
|