@homie-rocks/studio 0.56.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 +33 -0
- package/apps/APPS.md +9 -0
- package/apps/apps.ts +19 -1
- package/docs/studio-to-studio.md +248 -0
- package/functions/FUNCTIONS.md +9 -0
- package/lib/cloudflare.mjs +3 -0
- package/lib/needs-headless.mjs +1 -1
- package/lib/studios-keys.mjs +13 -0
- package/lib/template-history.json +42 -0
- package/netplay/NETPLAY.md +7 -0
- package/package.json +1 -1
- package/tools/TOOLS.md +16 -0
- package/tools/tools.ts +14 -1
- 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/mcp-store.mjs +3 -1
- package/worker/mcp-tools.mjs +25 -2
- package/worker/mcp.mjs +43 -8
- package/worker/room.mjs +29 -0
- package/worker/routes.mjs +1 -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/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,39 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [Unreleased]
|
|
21
|
+
|
|
22
|
+
### Fixed
|
|
23
|
+
|
|
24
|
+
- The MCP Events workerd conformance test now parses SSE frames across HTTP chunks, including combined event/heartbeat frames and split UTF-8. Its former one-chunk-per-event assumption caused the intermittent 0.57.0 publish-gate JSON parse failure; production delivery and webhook formats are unchanged. PR CI adds a path-filtered Events/webhook gate covering protocol, push, durable delivery and signed studio events.
|
|
25
|
+
|
|
26
|
+
## [0.57.0] - 2026-10-16
|
|
27
|
+
|
|
28
|
+
**Plugin 0.57.0**
|
|
29
|
+
|
|
30
|
+
Studios can book at each other on behalf of a person, with proven studio identity and signed events, and friends joining a browser-hosted game at the same moment always get their seat.
|
|
31
|
+
|
|
32
|
+
### Fixed
|
|
33
|
+
|
|
34
|
+
- Browser-hosted games retry connected players’ seat claims when the host’s roster has no player body for them. Friends joining while the host is still loading now become seated players when its world is ready, including games with older bundled netplay clients. Fixes the 0.56.0 regression where Kart Royale replicas stayed bots across rounds; the two-browser check retains its original early-join timing. Host-driven AI seats count as acknowledged; missing-seat retries use capped exponential backoff and stop after eight attempts with one diagnostic, avoiding repeated joins when a game has no available body.
|
|
35
|
+
- Studio ingress now limits requests by IP before discovery; failed JWKS fetches and unknown keys share an atomic 60-second per-origin fetch bound. Authentication errors are generic, with stage details only in owner audit logs. Signed events use the exact inbound path rule, honour disabled entries and reject non-object bodies with 400. Removed the unused v1-only webhook production dependency and documented exact manual key rotation steps through stdin.
|
|
36
|
+
|
|
37
|
+
- Studio-to-studio limits aggregate by proven origin, outbound destinations require explicit configuration, and remote results retain untrusted provenance. Asserted handles never identify local accounts.
|
|
38
|
+
- Availability allocates from a page snapshot in three queries instead of querying per slot, option and resource.
|
|
39
|
+
- Studio identity uses Ed25519 private-key JWT and origin-hosted JWKS; events use Standard Webhooks v1a with the same key. Deploy provisions private keys through stdin. Office Allow/Deny is stored in D1, denied by default, with origin/handle audit entries. Owners exchange no secrets.
|
|
40
|
+
- Studio events and business feed data share an 8,192-byte limit. Discovery and tokens are cached within each request; proof scripts use deploy URLs without reading Wrangler OAuth files.
|
|
41
|
+
|
|
42
|
+
### Added
|
|
43
|
+
|
|
44
|
+
- Studio-to-studio tools use the official MCP SDK with bounded HTTPS transport, receiver-owned OAuth client credentials and a verified studio caller distinct from an asserted end-user handle.
|
|
45
|
+
- Booking availability returns paged schema.org offers and exact booking arguments, shared by AI tools and the existing booking choices UI.
|
|
46
|
+
- Signed cross-studio events reuse Standard Webhooks, per-sender allowlists and durable business-event receipts to trigger studio functions.
|
|
47
|
+
- Tools can call their own custom routes with the caller's identity and share opt-in Cloudflare Rate Limiting bindings.
|
|
48
|
+
|
|
49
|
+
### Upgrade notes
|
|
50
|
+
|
|
51
|
+
- Studios that call or accept calls from other studios list them in studio.json (outbound) and allow them in the office (inbound); the deploy provisions each studio's signing key — nothing is exchanged between owners. Existing OAuth browser connections, booking migrations and function delivery remain in use. No version bump or new migration. See `packages/studio/docs/studio-to-studio.md`.
|
|
52
|
+
|
|
20
53
|
## [0.56.0] - 2026-10-15
|
|
21
54
|
|
|
22
55
|
**Plugin 0.56.0**
|
package/apps/APPS.md
CHANGED
|
@@ -339,3 +339,12 @@ compiler; run `npm install` in the studio first if it is missing. Review the dif
|
|
|
339
339
|
combined timer/jobs, hand-written routes and business-event mirrors need a semantic
|
|
340
340
|
migration to the APIs above; they are not automatically deleted. The owner's
|
|
341
341
|
words, historical evidence and unresolved integration work remain intact.
|
|
342
|
+
|
|
343
|
+
## Availability shared with an AI
|
|
344
|
+
|
|
345
|
+
`studio_availability` and `context.bookings.availability` return paged offers with
|
|
346
|
+
valid booking arguments, checking the existing slots, holds, options and resources.
|
|
347
|
+
Pass that result to `createBookingChoices({container,availability})`; `value()`
|
|
348
|
+
returns the selected offer's booking arguments for the same booking tool an AI
|
|
349
|
+
uses. Availability is a snapshot; authoritative holds still recheck capacity.
|
|
350
|
+
See [the convention](../docs/studio-to-studio.md#availability-convention-v).
|
package/apps/apps.ts
CHANGED
|
@@ -78,7 +78,25 @@ export function createAppContext() {
|
|
|
78
78
|
}
|
|
79
79
|
|
|
80
80
|
/** Native accessible choices; submit value() to the same booking tool used by an AI. */
|
|
81
|
-
export
|
|
81
|
+
export interface BookingAvailability {
|
|
82
|
+
convention: 'studio.booking-availability/1';
|
|
83
|
+
bookingTool: string;
|
|
84
|
+
items: Array<{identifier: string; name?: string; availabilityStarts?: string; booking: {app: string; collection: string; slot: string; quantity: number; option?: string; resource?: string; addons?: Record<string,number>}}>;
|
|
85
|
+
next: string|null;
|
|
86
|
+
}
|
|
87
|
+
export function createBookingChoices({container,options=[],resources=[],availability}:{container:HTMLElement;options?:Array<{id:string;name:string}>;resources?:Array<{id:string;name:string}>;availability?:BookingAvailability}) {
|
|
88
|
+
if(availability){
|
|
89
|
+
if(availability.convention!=='studio.booking-availability/1')throw new Error('Unsupported booking availability convention');
|
|
90
|
+
const label=document.createElement('label');label.textContent='Available bookings';
|
|
91
|
+
const select=document.createElement('select');select.name='booking';
|
|
92
|
+
const empty=document.createElement('option');empty.value='';empty.textContent='Choose a booking';select.append(empty);
|
|
93
|
+
const bookings=availability.items.map(item=>structuredClone(item.booking));
|
|
94
|
+
for(const [index,item] of availability.items.entries()){
|
|
95
|
+
const option=document.createElement('option');option.value=String(index);option.textContent=[item.name??item.booking.slot,item.availabilityStarts].filter(Boolean).join(' · ');select.append(option);
|
|
96
|
+
}
|
|
97
|
+
label.append(select);container.append(label);
|
|
98
|
+
return {value:():Partial<BookingAvailability['items'][number]['booking']>=>select.value===''?{}:structuredClone(bookings[Number(select.value)]),dispose:()=>label.remove()};
|
|
99
|
+
}
|
|
82
100
|
const fields=new Map<string,HTMLSelectElement>();
|
|
83
101
|
const group=document.createElement('fieldset');
|
|
84
102
|
const legend=document.createElement('legend');legend.textContent='Choose an option';group.append(legend);
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
# One studio acting at another
|
|
2
|
+
|
|
3
|
+
A concierge can discover a venue, select a visit, reserve it for a person and tell
|
|
4
|
+
its host when they will arrive. Each studio runs its own tools, OAuth, database and
|
|
5
|
+
functions. The same mechanisms apply to a resort stay or any other collaboration.
|
|
6
|
+
|
|
7
|
+
## Client (t)
|
|
8
|
+
|
|
9
|
+
Tools and functions receive `context.studios`:
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
const origin = 'https://venue.example';
|
|
13
|
+
const page = await ctx.studios.listTools(origin); // pass {cursor: page.nextCursor}
|
|
14
|
+
const available = await ctx.studios.call(origin, 'studio_availability', {
|
|
15
|
+
app: 'venue', collection: 'bookings'
|
|
16
|
+
});
|
|
17
|
+
if (available.isError) throw new Error('Availability could not be read');
|
|
18
|
+
// Inspect available.data.items, choose an offer, then:
|
|
19
|
+
const booked = await ctx.studios.call(origin, 'studio_book', {
|
|
20
|
+
...chosenOffer.booking, id: stableBookingId, expiresAt: Date.now() + 600000
|
|
21
|
+
}, {assertedHandle: 'guest-42'});
|
|
22
|
+
if (booked.isError) throw new Error('Reservation was refused');
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
The official MCP TypeScript client owns initialization, Streamable HTTP/SSE,
|
|
26
|
+
OAuth discovery, client authentication, schema validation and protocol errors.
|
|
27
|
+
`call` returns `{source:{origin,authenticated},untrusted:true,isError,data?,content}`. Structured content is preserved; the
|
|
28
|
+
kit's legacy `untrusted-tool-data` envelope is decoded while retaining the untrusted marker and provenance. `listTools` and each description are also labelled untrusted. Before any `ctx.ai` call, wrap remote data and descriptions as untrusted input, preserving `source` and `untrusted`; never concatenate them into system instructions. Content and
|
|
29
|
+
`data` remain untrusted business data, never instructions or verified claims.
|
|
30
|
+
Transport failures reject; tool failures return `isError:true`. No automatic
|
|
31
|
+
booking or payment retries are introduced. Use stable business IDs for retries.
|
|
32
|
+
|
|
33
|
+
Each operation has a ten-second deadline including discovery, a 1 MiB response
|
|
34
|
+
limit before SDK parsing, and a 64 KiB argument limit. Origins must be bare HTTPS
|
|
35
|
+
origins on port 443 with public DNS names. IP literals (including IPv6, mapped and integer forms), localhost, .localhost, .local and .internal are refused unless that exact outbound entry explicitly sets `allowPrivate:true`. Config keys are normalized; duplicate normalized keys or mismatched `origin` fields fail. Never take an origin from model or user input without checking it against a list of permitted origins. All redirects are refused. Every fetch, including OAuth metadata and token
|
|
36
|
+
requests, must stay on the chosen origin. Credentials are issuer-pinned. A client
|
|
37
|
+
and its transport close after each operation; discovery and tokens are cached per origin within the request; tool listing is cursor-paged.
|
|
38
|
+
Keep the toolkit's `global_fetch_strictly_public` compatibility flag for
|
|
39
|
+
Worker-to-Worker HTTP calls. Every destination must be configured and delegated to the calling tool. Public anonymous calls require `anonymous:true` on that exact outbound entry. An unknown origin never falls back to anonymous. Studio code is trusted Worker
|
|
40
|
+
code: these safeguards are not a sandbox for imported JavaScript. Choose trusted
|
|
41
|
+
remote operators; this does not implement DNS address pinning.
|
|
42
|
+
|
|
43
|
+
## Studio identity and allowlists (u)
|
|
44
|
+
|
|
45
|
+
Each studio deploy generates its own Ed25519 key into the `STUDIO_SIGNING_KEY`
|
|
46
|
+
Worker secret via stdin. Private keys never appear in model output, source or
|
|
47
|
+
studio.json. Existing keys survive redeploys. Owners exchange nothing.
|
|
48
|
+
The caller publishes only public keys at `https://<origin>/.well-known/jwks.json`.
|
|
49
|
+
The receiver fetches that exact origin with bounded responses and no redirects.
|
|
50
|
+
|
|
51
|
+
B declares permitted capabilities:
|
|
52
|
+
|
|
53
|
+
```json
|
|
54
|
+
{"mcp":{"studios":{"inbound":{"concierge":{
|
|
55
|
+
"origin":"https://concierge.example",
|
|
56
|
+
"tools":["studio_availability","studio_book"]
|
|
57
|
+
}}}}}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
A delegates the outbound destination:
|
|
61
|
+
|
|
62
|
+
```json
|
|
63
|
+
{"mcp":{"studios":{"outbound":{"https://venue.example":{
|
|
64
|
+
"tools":["plan_drive"]
|
|
65
|
+
}}}}}
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
B's owner opens **Office → AI connections** and clicks **Allow** for the
|
|
69
|
+
configured calling origin. All start denied. Allow/Deny is stored in D1 and takes
|
|
70
|
+
effect on every dispatch without redeploying, including existing tokens. Tool and
|
|
71
|
+
event capability declarations remain in configuration. Audit entries show the
|
|
72
|
+
calling origin and the asserted handle, not a misleading local account name.
|
|
73
|
+
|
|
74
|
+
`/oauth/token` accepts `client_credentials` with RFC 7523 `private_key_jwt`,
|
|
75
|
+
`scope=studio`, and the receiver's `/mcp` resource. The client ID, issuer and subject
|
|
76
|
+
are the caller's canonical origin; audience is the exact receiver token endpoint.
|
|
77
|
+
Assertions have short expiry, `kid`, and a one-use `jti` checked atomically in D1.
|
|
78
|
+
The receiver accepts Ed25519/EdDSA keys. Five-minute opaque access tokens are hashed
|
|
79
|
+
in D1. Browser OAuth continues using the existing provider.
|
|
80
|
+
|
|
81
|
+
JWKS cache lifetime is five minutes. Failed fetches are cached for 60 seconds;
|
|
82
|
+
unknown `kid` refreshes are limited to once per origin per 60 seconds, including
|
|
83
|
+
concurrent requests. Token and studio-hook requests share a mandatory limit of
|
|
84
|
+
60 requests per IP per minute before key discovery. Throttled requests return
|
|
85
|
+
429 with `Retry-After: 60`. Authentication failures return the same generic 401;
|
|
86
|
+
only the owner-visible audit log records the failed stage.
|
|
87
|
+
|
|
88
|
+
### Manual key rotation
|
|
89
|
+
|
|
90
|
+
Rotation is manual today. From the studio's `site/` directory (the directory with
|
|
91
|
+
its deployed `wrangler.jsonc`), run the following with the deployed origin in
|
|
92
|
+
`STUDIO_ORIGIN`. These commands fetch only public keys; the new private key stays
|
|
93
|
+
in the Node process and Wrangler stdin. Do not enable shell tracing or print the
|
|
94
|
+
bulk input. Use the same Wrangler environment/account as the existing deployment.
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
export STUDIO_ORIGIN=https://your-studio.example
|
|
98
|
+
node --input-type=module <<'NODE' | npx wrangler secret bulk
|
|
99
|
+
import {generateKeyPairSync, randomUUID} from 'node:crypto';
|
|
100
|
+
const response = await fetch(new URL('/.well-known/jwks.json', process.env.STUDIO_ORIGIN), {redirect:'error'});
|
|
101
|
+
if (!response.ok) throw new Error('Cannot read existing public keys; rotation aborted');
|
|
102
|
+
const jwks = await response.json();
|
|
103
|
+
if (!Array.isArray(jwks.keys) || !jwks.keys.length || jwks.keys.length > 7 ||
|
|
104
|
+
jwks.keys.some(k => !k || k.d || k.kty !== 'OKP' || k.crv !== 'Ed25519' || !k.x || !k.kid))
|
|
105
|
+
throw new Error('Invalid or too many retained public keys; rotation aborted');
|
|
106
|
+
const previous = jwks.keys.map(({kty,crv,x,kid}) => ({kty,crv,x,kid,alg:'EdDSA',use:'sig'}));
|
|
107
|
+
const {privateKey} = generateKeyPairSync('ed25519');
|
|
108
|
+
const next = {...privateKey.export({format:'jwk'}), kid:randomUUID()};
|
|
109
|
+
process.stdout.write(JSON.stringify({
|
|
110
|
+
STUDIO_PREVIOUS_PUBLIC_KEYS: JSON.stringify(previous),
|
|
111
|
+
STUDIO_SIGNING_KEY: JSON.stringify(next)
|
|
112
|
+
}));
|
|
113
|
+
NODE
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
1. Confirm Wrangler reports that **both** secrets were updated. If the command
|
|
117
|
+
fails, inspect the public JWKS and Wrangler deployment status before retrying.
|
|
118
|
+
2. Check `curl --fail "$STUDIO_ORIGIN/.well-known/jwks.json"`: it must publish the
|
|
119
|
+
new `kid` and the previous public keys, with no `d` field. Peer caches may take
|
|
120
|
+
up to 60 seconds to refresh for the new `kid`; retry failed operations with
|
|
121
|
+
their original stable business/event IDs.
|
|
122
|
+
3. Retain the old public keys for at least ten minutes after successful rotation.
|
|
123
|
+
Then, provided no other rotation is still in its overlap period, remove them:
|
|
124
|
+
`printf '%s' '[]' | npx wrangler secret put STUDIO_PREVIOUS_PUBLIC_KEYS`.
|
|
125
|
+
4. Check the public JWKS again: only the new public key should remain. Existing
|
|
126
|
+
signing keys survive future normal deploys. Deny the origin immediately if a
|
|
127
|
+
key is compromised; rotation overlap is not emergency revocation.
|
|
128
|
+
|
|
129
|
+
```ts
|
|
130
|
+
ctx.caller.studio // {origin:'https://concierge.example', verified:true,
|
|
131
|
+
// method:'private_key_jwt'}
|
|
132
|
+
ctx.caller.actingFor // {assertedHandle:'guest-42', verified:false,
|
|
133
|
+
// assertedBy:'https://concierge.example'}
|
|
134
|
+
```
|
|
135
|
+
|
|
136
|
+
Verified means the caller proved possession of a key published by that HTTPS
|
|
137
|
+
origin. It does not verify any person. `assertedHandle` permits only
|
|
138
|
+
`[A-Za-z0-9._@-]`, 1–128 characters. Never match it to a local account or grant
|
|
139
|
+
roles from it. It only separates bookings within the calling studio. A studio can
|
|
140
|
+
assert any handle within its own origin; all handles share origin-level rate
|
|
141
|
+
limits. Studio callers use only allowlisted public tools, never signed-in roles.
|
|
142
|
+
|
|
143
|
+
References: [RFC 7523](https://www.rfc-editor.org/rfc/rfc7523) and the
|
|
144
|
+
[MCP client credentials extension](https://github.com/modelcontextprotocol/ext-auth/blob/main/specification/draft/oauth-client-credentials.mdx).
|
|
145
|
+
|
|
146
|
+
## Availability convention (v)
|
|
147
|
+
|
|
148
|
+
`studio_availability({app,collection,after?,quantity?,option?,resource?,addons?})`
|
|
149
|
+
and `ctx.bookings.availability(...)` return:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{"convention":"studio.booking-availability/1","bookingTool":"studio_book",
|
|
153
|
+
"items":[{"@type":"Offer","identifier":"[\"morning\",null]",
|
|
154
|
+
"availability":"https://schema.org/InStock","remaining":2,
|
|
155
|
+
"availabilityStarts":"2030-10-15T10:00:00Z",
|
|
156
|
+
"availabilityEnds":"2030-10-15T11:00:00Z",
|
|
157
|
+
"booking":{"app":"venue","collection":"bookings","slot":"morning","quantity":1}}],
|
|
158
|
+
"next":null}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The snapshot loads a page of slots, resource records and active allocations in three queries regardless of slot × option count (plus access-policy checks), and uses the same
|
|
162
|
+
resource/option allocator as `studio_book`. It checks app visibility and slot-read
|
|
163
|
+
grants. A page scans at most 50 slots and supports at most 64 alternatives per
|
|
164
|
+
slot; `next` can be non-null even when no offers on this page are available.
|
|
165
|
+
Only feasible options are returned. Add-ons and preferred resources can be checked
|
|
166
|
+
as query inputs. No booking is taken during discovery. Holds still enforce capacity
|
|
167
|
+
atomically; concurrent clients may see the same offer and only one may book.
|
|
168
|
+
Expired holds continue occupying capacity until the existing expiry function
|
|
169
|
+
releases them, matching the authoritative hold path.
|
|
170
|
+
|
|
171
|
+
The existing `createBookingChoices({container,availability})` UI part accepts this
|
|
172
|
+
same result and returns the selected offer's exact booking arguments from `value()`.
|
|
173
|
+
Its existing explicit `options`/`resources` mode remains available. Share custom
|
|
174
|
+
booking parts with an availability tool returning this convention and name their
|
|
175
|
+
booking tool in `bookingTool`.
|
|
176
|
+
|
|
177
|
+
[Schema.org Offer](https://schema.org/Offer) supplies `availability`, `identifier`
|
|
178
|
+
and availability dates. A successful hold corresponds to a
|
|
179
|
+
[Reservation](https://schema.org/Reservation): its `id` maps to `reservationId`,
|
|
180
|
+
`state` to reservation status, and the chosen slot to `reservationFor`. The
|
|
181
|
+
kit keeps its existing versioned hold response and payment lifecycle. Local
|
|
182
|
+
`booking`, `remaining`, pagination and convention fields are explicit extensions;
|
|
183
|
+
this is not a claim that schema.org defines a booking API.
|
|
184
|
+
|
|
185
|
+
The [Agentic Commerce Protocol](https://github.com/agentic-commerce-protocol/agentic-commerce-protocol)
|
|
186
|
+
standardizes commerce/checkout interactions. Its separation of available offers
|
|
187
|
+
from authoritative checkout is compatible with this convention, but it does not
|
|
188
|
+
supply this toolkit's time-slot and multi-resource allocation schema. Availability
|
|
189
|
+
does not invent checkout, represent a payment approval, or claim ACP conformance.
|
|
190
|
+
|
|
191
|
+
## Signed arrival events (o)
|
|
192
|
+
|
|
193
|
+
Use Standard Webhooks asymmetric `v1a` Ed25519 signatures with the same studio key and the envelope `{eventId,name,timestamp,source,data}`. On A's outbound destination, add:
|
|
194
|
+
|
|
195
|
+
```json
|
|
196
|
+
{"eventPath":"/hooks/studios/concierge","events":["arrival"]}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
On B's inbound `concierge` entry, add
|
|
200
|
+
`"events":{"arrival":"arrivals"}`. No shared event secret is needed. B's `mcp.events.arrivals` declares `event:"guest.arriving"`, an audience,
|
|
201
|
+
and a schema for `{sender:{origin,verified:true},data:{booking,minutes}}`.
|
|
202
|
+
Only mapped names can publish to local feeds. Define a function with
|
|
203
|
+
`event:'guest.arriving'` to consume `event.data.data` and its verified
|
|
204
|
+
`event.data.sender`. The sender cannot select an arbitrary local event type.
|
|
205
|
+
|
|
206
|
+
```ts
|
|
207
|
+
await ctx.studios.send('https://venue.example', 'arrival', {
|
|
208
|
+
id: stableArrivalId, data: {booking: bookingId, minutes: 10}
|
|
209
|
+
});
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
The JavaScript `standardwebhooks` package currently supports HMAC `v1` only, so
|
|
213
|
+
studio `v1a` uses Web Crypto directly, with fixed cryptographic and spec-framing
|
|
214
|
+
regression vectors. The receiver verifies Standard Webhooks `id.timestamp.body` signatures with Web Crypto Ed25519, including a five-minute timestamp window. Both studio event envelopes and business-feed data are limited to **8,192 UTF-8 bytes**; oversized events return a clear 413 error. `webhook-key-id` selects the origin JWKS key; `webhook-origin` must match the configured sender and signed body. IDs are 1–128 letters,
|
|
215
|
+
digits, underscores or hyphens. A receipt is committed atomically with the event
|
|
216
|
+
using existing business-event publication. Identical retries return 204 without
|
|
217
|
+
running the function again; a signed changed payload under the same ID returns
|
|
218
|
+
409. Receipts survive event pruning. Function execution remains at least once:
|
|
219
|
+
handlers must deduplicate external effects by event ID. If acceptance fails, the
|
|
220
|
+
sender gets an error and can retry the same ID. Calls to `send` do not create an
|
|
221
|
+
outbound retry queue; call from an existing durable function for automatic retries.
|
|
222
|
+
The existing configured-feed/MCP Events subscription dispatcher remains available
|
|
223
|
+
for subscription delivery. No new event bus or MCP method is introduced.
|
|
224
|
+
|
|
225
|
+
## Friend routes and rate limits (p, q)
|
|
226
|
+
|
|
227
|
+
`await ctx.fetchSelf('/api/friend-action', {method:'POST', body:...})` invokes the
|
|
228
|
+
studio's registered `useRoutes` handler in-process. It carries the caller through
|
|
229
|
+
the existing internal identity mechanism. `isOwner`, `hasRole`, and the studio's
|
|
230
|
+
player lookup still perform their normal checks. Explicit cookies/Authorization
|
|
231
|
+
headers, URL credentials and other origins are refused. A missing custom route
|
|
232
|
+
returns 404. `fetchSelf` sets `Origin` to the local studio origin, so a custom route’s same-origin/CSRF check will pass; that check is not authorization. Routes must still check the propagated identity and roles. It does not borrow `fetchWithSecret` or mint an owner credential.
|
|
233
|
+
|
|
234
|
+
`await ctx.rateLimit('friends')` uses a declared Cloudflare Rate Limiting binding,
|
|
235
|
+
sharing the same namespace and account key between delegated tools:
|
|
236
|
+
|
|
237
|
+
```json
|
|
238
|
+
{"mcp":{"rateLimits":{"friends":{"binding":"FRIEND_RATE","tools":["join","reserve"]}}}}
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
In wrangler.jsonc, configure `ratelimits` with a stable `namespace_id`, name
|
|
242
|
+
`FRIEND_RATE` and `simple:{limit:30,period:60}`. The helper returns `{success}`;
|
|
243
|
+
the tool chooses its refusal response. No policy is enabled by default. Signed-in
|
|
244
|
+
callers default to their identity key; studio callers default to `studio:<origin>` regardless of asserted handle. Public MCP callers expose `ctx.caller.clientAddressKey` and use that address-based key by default. Do not let customers select keys to bypass your policy.
|
|
245
|
+
[Cloudflare's binding](https://developers.cloudflare.com/workers/runtime-apis/bindings/rate-limit/)
|
|
246
|
+
is an approximate per-location limiter, not a globally strict quota or booking
|
|
247
|
+
capacity mechanism. Existing authoritative booking/database checks remain necessary.
|
|
248
|
+
|
package/functions/FUNCTIONS.md
CHANGED
|
@@ -424,3 +424,12 @@ or declared webhook; functions never send email/SMS themselves.
|
|
|
424
424
|
Apply `0028_business_events.sql` on upgrade. Local proof publications carry their
|
|
425
425
|
scope and do not appear in production feed reads. Event feeds retain their declared
|
|
426
426
|
type; give different business feeds distinct types when their content differs.
|
|
427
|
+
|
|
428
|
+
## Events from another studio
|
|
429
|
+
|
|
430
|
+
An explicitly allowed studio can send a Standard Webhooks arrival event to
|
|
431
|
+
`/hooks/studios/<connection>`. The receiver maps allowed names to declared business
|
|
432
|
+
feeds and commits their existing durable receipt before acknowledging. Functions
|
|
433
|
+
receive the verified sender origin separately from the sender's untrusted data.
|
|
434
|
+
Use `context.studios.send` inside a durable function when sending needs retries.
|
|
435
|
+
See [studio-to-studio](../docs/studio-to-studio.md) for setup and replay semantics.
|
package/lib/cloudflare.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {ensureStudioSigningKey} from './studios-keys.mjs';
|
|
1
2
|
import {redactText} from './redact.mjs';
|
|
2
3
|
import {enableReadReplication} from './read-replication.mjs';
|
|
3
4
|
import {ensureBackgroundQueues,backgroundQueues} from './background-queue.mjs';
|
|
@@ -440,6 +441,7 @@ async function deployLocked(root, { log = () => {}, homie, fetchFn = null, ownRo
|
|
|
440
441
|
dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
441
442
|
}
|
|
442
443
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
|
|
444
|
+
try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
|
|
443
445
|
if (paidReleases(root).length) {
|
|
444
446
|
const bin = wranglerBin(root);
|
|
445
447
|
const uploadOptions = { cwd: workerDir(root), env: { ...process.env, CLOUDFLARE_ACCOUNT_ID: accountId, WRANGLER_SEND_METRICS: 'false', CI: '1' } };
|
|
@@ -664,6 +666,7 @@ export async function ciDeploy(root, { log = () => {}, wrangler = null } = {}) {
|
|
|
664
666
|
const started = Date.now();
|
|
665
667
|
const dep = w(['deploy', ...(workerEntry(root) ? [workerEntry(root)] : []), ...repoVar(root)]);
|
|
666
668
|
if (dep.code !== 0) return refuse(`wrangler deploy failed: ${wranglerDeployError(dep.out)}`, dep.out);
|
|
669
|
+
try{await ensureStudioSigningKey(readStudio(root),w);}catch(error){return refuse(error.message);}
|
|
667
670
|
step(`deployed ${names.worker ?? 'the Worker'} in ${Math.round((Date.now() - started) / 1000)} s`);
|
|
668
671
|
const repo = repoOf(root);
|
|
669
672
|
step(repo ? `the live site knows its GitHub repository (${repo}), for the Claude app's hand-off` : 'no GitHub repository found here (no HOMIE_REPO, studio.json "github" or git remote): the Claude app\'s card asks the person which it is');
|
package/lib/needs-headless.mjs
CHANGED
|
@@ -26,7 +26,7 @@ export function needsHeadless({url,secret,app,meta,scope,sessionFor}) {
|
|
|
26
26
|
if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
|
|
27
27
|
const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
|
|
28
28
|
if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
|
|
29
|
-
if(result.structuredContent!==undefined)
|
|
29
|
+
if(result.structuredContent!==undefined){const value=result.structuredContent;return value?.kind==='untrusted-tool-data'?value.data:value;}
|
|
30
30
|
const content=result.content?.filter(c=>c.type==='text');
|
|
31
31
|
if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
|
|
32
32
|
return result;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import {generateKeyPairSync,randomUUID} from 'node:crypto';
|
|
2
|
+
/** Key material travels only in memory and Wrangler stdin. Never return it or include command output in errors. */
|
|
3
|
+
export async function ensureStudioSigningKey(studio,w){
|
|
4
|
+
if(!studio.mcp?.studios)return;
|
|
5
|
+
const result=await w(['secret','list']);
|
|
6
|
+
if(result.code!==0)throw new Error('Cannot inspect studio signing key bindings');
|
|
7
|
+
let names;try{names=JSON.parse(result.stdout??result.out);}catch{throw new Error('Cannot inspect studio signing key bindings');}
|
|
8
|
+
if(names.some(entry=>entry.name==='STUDIO_SIGNING_KEY'))return;
|
|
9
|
+
const {privateKey}=generateKeyPairSync('ed25519');
|
|
10
|
+
const key={...privateKey.export({format:'jwk'}),kid:randomUUID()};
|
|
11
|
+
const saved=await w(['secret','bulk'],{input:JSON.stringify({STUDIO_SIGNING_KEY:JSON.stringify(key)})});
|
|
12
|
+
if(saved.code!==0)throw new Error('Cannot provision studio signing key');
|
|
13
|
+
}
|
|
@@ -4092,6 +4092,48 @@
|
|
|
4092
4092
|
}
|
|
4093
4093
|
}
|
|
4094
4094
|
}
|
|
4095
|
+
},
|
|
4096
|
+
"0.57.0": {
|
|
4097
|
+
"files": {
|
|
4098
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4099
|
+
"README.md": "684b3cca8001673c",
|
|
4100
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4101
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4102
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4103
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4104
|
+
"posts/README.md": "37841aae476bd540",
|
|
4105
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4106
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4107
|
+
},
|
|
4108
|
+
"sections": {
|
|
4109
|
+
"AGENTS.md": {
|
|
4110
|
+
"(intro)": "af7f2f732505d379",
|
|
4111
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4112
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4113
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4114
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4115
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4116
|
+
"## Making games": "bb715272fd1a615b",
|
|
4117
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4118
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4119
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4120
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4121
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4122
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4123
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4124
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4125
|
+
"## Beta": "9d02638207a26e73"
|
|
4126
|
+
}
|
|
4127
|
+
},
|
|
4128
|
+
"named": {
|
|
4129
|
+
"Homie Arcade": {
|
|
4130
|
+
"sections": {
|
|
4131
|
+
"AGENTS.md": {
|
|
4132
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4133
|
+
}
|
|
4134
|
+
}
|
|
4135
|
+
}
|
|
4136
|
+
}
|
|
4095
4137
|
}
|
|
4096
4138
|
}
|
|
4097
4139
|
}
|
package/netplay/NETPLAY.md
CHANGED
|
@@ -290,6 +290,13 @@ at once, with bots filling the slots (Gem Rush: 1 human + 2 bots).
|
|
|
290
290
|
- A human who leaves turns back into a bot in the same body.
|
|
291
291
|
- `trim()` removes surplus bots between rounds.
|
|
292
292
|
- After every change the host calls `net.roster(slots)`.
|
|
293
|
+
- A browser host may receive `join` before its world is ready. Once it publishes a roster,
|
|
294
|
+
snapshot traffic makes the relay retry any connected replica seat missing from that roster,
|
|
295
|
+
with delays doubling from 1 second to a 30-second cap, for at most eight retries per peer
|
|
296
|
+
and host. A roster seat (including `agent.seat` for host-driven AI) acknowledges the claim.
|
|
297
|
+
If still unacknowledged after the final wait, the relay logs once and stops retrying. Treat a repeated `join` for an
|
|
298
|
+
already claimed seat as idempotent; keep its body and score. This uses the existing wire
|
|
299
|
+
message, including for older bundled clients. Rules hosts reconcile seats separately.
|
|
293
300
|
|
|
294
301
|
**Join in progress.**
|
|
295
302
|
1. The `welcome` carries the last snapshot, round, roster and **all keyed state**. The
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.57.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/tools/TOOLS.md
CHANGED
|
@@ -193,3 +193,19 @@ Use `/mcp/auth` for owner and staff connectors. Its anonymous 401 includes the
|
|
|
193
193
|
Both transports use the same `/mcp` OAuth resource; `/mcp` remains public without a
|
|
194
194
|
token. An approved connection retains the account’s current app grants, not a copy
|
|
195
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>;
|
|
@@ -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),
|
package/worker/mcp-store.mjs
CHANGED
|
@@ -42,7 +42,7 @@ export async function rateLimit(env, caller, limit = null) {
|
|
|
42
42
|
return row.n <= limit;
|
|
43
43
|
}
|
|
44
44
|
export async function audit(env, caller, tool, outcome) {
|
|
45
|
-
await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.id ?? null,caller.client ?? null,tool,Date.now(),outcome).run();
|
|
45
|
+
await env.DB.prepare('INSERT INTO mcp_audit(person,client,tool,at,outcome) VALUES(?1,?2,?3,?4,?5)').bind(caller.studio?(caller.actingFor?.assertedHandle??null):caller.id ?? null,caller.studio?.origin??caller.client ?? null,tool,Date.now(),outcome).run();
|
|
46
46
|
// Bounded storage, and no cleanup timer or writes on ordinary page traffic.
|
|
47
47
|
await env.DB.batch([
|
|
48
48
|
env.DB.prepare('DELETE FROM mcp_audit WHERE id <= (SELECT MAX(id)-10000 FROM mcp_audit)'),
|
|
@@ -55,3 +55,5 @@ export function callsPerMinute(cat) {
|
|
|
55
55
|
const value=cat.studio?.mcp?.callsPerMinute;
|
|
56
56
|
return Number.isSafeInteger(value)&&value>0?value:null;
|
|
57
57
|
}
|
|
58
|
+
|
|
59
|
+
export const callerLimitKey=caller=>caller.studio?'studio:'+caller.studio.origin:caller.id??caller.clientAddressKey;
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {callerLimitKey} from './mcp-store.mjs';
|
|
2
|
+
import {bookingAvailability} from './booking-availability.mjs';
|
|
3
|
+
import {studiosClient} from './studios-client.mjs';
|
|
1
4
|
import {bookingInputSchema,normalizeBookingArgs} from './booking-options.mjs';
|
|
2
5
|
import {bookingHolds} from './booking-holds.mjs';
|
|
3
6
|
import {toolDatabase} from './tool-database.mjs';
|
|
@@ -12,7 +15,7 @@ export const object = (properties = {}, required = []) => ({type:'object',proper
|
|
|
12
15
|
const str = {type:'string',maxLength:128};
|
|
13
16
|
export async function allowed(audience, caller, env) {
|
|
14
17
|
if (audience === 'public') return true;
|
|
15
|
-
if (!caller.id) return false;
|
|
18
|
+
if (!caller.id || caller.studio) return false;
|
|
16
19
|
if (audience === 'signed-in') return true;
|
|
17
20
|
if (audience === 'studio-staff') return caller.owner === true || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app='*' AND role='staff' AND player=?1").bind(caller.id).first());
|
|
18
21
|
if (audience === 'owner') return caller.owner === true;
|
|
@@ -70,12 +73,31 @@ export function services(env, cat, origin, caller, namespace, definition = {}) {
|
|
|
70
73
|
const key = v=>{if(typeof v!=='string'||v.length>128||!v.length)throw new Error('invalid key');return v;};
|
|
71
74
|
return Object.freeze({
|
|
72
75
|
caller:Object.freeze({...caller,roles:Object.freeze(Object.fromEntries(Object.entries(caller.roles??{}).map(([app,roles])=>[app,Object.freeze([...roles])])) )}), records, office,
|
|
73
|
-
|
|
76
|
+
studios:studiosClient(env,cat,definition,{origin}),
|
|
77
|
+
bookings:Object.freeze({...bookingHolds(env,caller,cat),availability:args=>bookingAvailability(env,cat,caller,args)}),
|
|
74
78
|
events:Object.freeze({publish:args=>import('./business-events.mjs').then(m=>m.publishBusinessEvent(env,caller,definition,namespace,args))}),
|
|
75
79
|
rooms:Object.freeze({send:args=>import('./tool-events.mjs').then(m=>m.sendToolEvent(env,caller,definition,args))}),
|
|
76
80
|
shop:()=>office('/_studio/api/shop'),
|
|
77
81
|
database:toolDatabase(env.DB,namespace),
|
|
78
82
|
fetch:globalThis.fetch.bind(globalThis),
|
|
83
|
+
async fetchSelf(path,init={}) {
|
|
84
|
+
const url=new URL(path,origin);
|
|
85
|
+
if(url.origin!==new URL(origin).origin || url.username || url.password)throw new Error('Same origin required');
|
|
86
|
+
const headers=new Headers(init.headers);
|
|
87
|
+
if(headers.has('authorization')||headers.has('cookie'))throw new Error('Explicit credentials refused');
|
|
88
|
+
headers.set('origin',new URL(origin).origin);
|
|
89
|
+
const request=withToolIdentity(new Request(url,{...init,headers,redirect:'error'}),caller);
|
|
90
|
+
const pending=[];
|
|
91
|
+
const response=await (await import('./custom-routes.mjs')).customRoute(request,env,{waitUntil:promise=>pending.push(promise)});
|
|
92
|
+
await Promise.all(pending);
|
|
93
|
+
return response??new Response('Not found',{status:404});
|
|
94
|
+
},
|
|
95
|
+
async rateLimit(name,key=callerLimitKey(caller)) {
|
|
96
|
+
const rule=cat.studio?.mcp?.rateLimits?.[name];
|
|
97
|
+
if(!rule?.tools?.includes(definition.name)||!env[rule.binding]?.limit)throw new Error('Rate limit not declared for this tool');
|
|
98
|
+
if(typeof key!=='string'||!key.length||key.length>256)throw new Error('Rate limit key required (1–256 characters)');
|
|
99
|
+
return env[rule.binding].limit({key:JSON.stringify([name,key])});
|
|
100
|
+
},
|
|
79
101
|
// A named secret may authenticate an HTTPS request; its value is never returned.
|
|
80
102
|
async fetchWithSecret(name,url,init={}) {
|
|
81
103
|
const rule=cat.studio?.mcp?.secrets?.[name];
|
|
@@ -178,6 +200,7 @@ export function builtins(env,cat,origin) {
|
|
|
178
200
|
const collections=new Set(Object.values(roles).flatMap(r=>r.can.map(cap=>cap.split(':')[1])));
|
|
179
201
|
return {app,roles,collections:Object.fromEntries(Object.entries(meta.records?.collections??{}).filter(([name])=>collections.has(name)))};
|
|
180
202
|
}),
|
|
203
|
+
tool('studio_availability','List available booking slots and options before studio_book. Returns schema.org Offers with exact booking arguments; availability is a snapshot, and booking rechecks capacity.','public',object({app:str,collection:str,after:str,quantity:{type:'integer',minimum:1},option:str,resource:str,addons:{type:'object',additionalProperties:{type:'integer',minimum:1,maximum:100}}},['app','collection']),async(args,ctx)=>ctx.bookings.availability(args)),
|
|
181
204
|
tool('studio_book','Reserve a declared booking slot. Accepts slot/time, resource/preferredResource, and addons/addOns as an array or quantity object. Supply a stable id, future expiresAt and a private token for an anonymous booking. Payment is separate.','public',bookingInputSchema,async(input,ctx)=>{
|
|
182
205
|
const args=normalizeBookingArgs(input),meta=cat.games.find(g=>g.id===args.app&&g.kind==='app'),spec=meta?.records?.collections?.[args.collection]?.booking;
|
|
183
206
|
if(!spec)throw Object.assign(new Error('Unknown booking collection'),{name:'StudioOperationError'});
|
package/worker/mcp.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {studioOrigin} from './studios-client.mjs';
|
|
2
|
+
import {studioToken,studioCaller,setStudioPolicy,studioRegistration} from './studios-auth.mjs';
|
|
1
3
|
import {tokenCaller,toolInScope} from './connector-token.mjs';
|
|
2
4
|
import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
|
|
3
5
|
import {eventsRequest} from './mcp-events.mjs';
|
|
@@ -17,7 +19,7 @@ import { players } from './players.mjs';
|
|
|
17
19
|
import { isOwner, sameOrigin } from './office.mjs';
|
|
18
20
|
import { cookieValue } from './stats.mjs';
|
|
19
21
|
import { esc } from './site.mjs';
|
|
20
|
-
import { oauthStorage, rateLimit, audit, callsPerMinute } from './mcp-store.mjs';
|
|
22
|
+
import { oauthStorage, rateLimit, audit, callsPerMinute, callerLimitKey } from './mcp-store.mjs';
|
|
21
23
|
import { allowed, currentCaller, services, builtins } from './mcp-tools.mjs';
|
|
22
24
|
const validator = new CfWorkerJsonSchemaValidator();
|
|
23
25
|
const json = (data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store'}});
|
|
@@ -36,14 +38,28 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
36
38
|
const url=new URL(request.url), origin=url.origin, path=url.pathname;
|
|
37
39
|
if(!env.DB)return json({error:'studio database required'},503);
|
|
38
40
|
if(request.headers.has('origin') && request.headers.get('origin')!==origin)return json({error:'origin refused'},403);
|
|
41
|
+
if(path==='/.well-known/jwks.json')return Response.json((await import('./studios-keys.mjs')).publicKeys(env),{headers:{'cache-control':'public, max-age=60'}});
|
|
42
|
+
if(path==='/oauth/token'||path.startsWith('/hooks/studios/')) {
|
|
43
|
+
if(!await rateLimit(env,'studio-ingress:'+(request.headers.get('cf-connecting-ip')??'local'),60))
|
|
44
|
+
return Response.json({error:'rate_limit_exceeded'},{status:429,headers:{'retry-after':'60','cache-control':'no-store'}});
|
|
45
|
+
}
|
|
46
|
+
if(path.startsWith('/hooks/studios/')) {
|
|
47
|
+
const cat=await catalogueOf();
|
|
48
|
+
const response=await (await import('./studios-events.mjs')).receiveStudioEvent(request,env,cat);
|
|
49
|
+
if(response.ok){const delivery=import('./functions.mjs').then(m=>m.runFunctions(env,cat,origin));if(ctx?.waitUntil)ctx.waitUntil(delivery);else await delivery;}
|
|
50
|
+
return response;
|
|
51
|
+
}
|
|
39
52
|
if(path.startsWith('/hooks/tools/')) return (await import('./tool-webhook.mjs')).toolWebhook(request,env,{cat:await catalogueOf(),definitions});
|
|
53
|
+
if(path==='/oauth/token'){const response=await studioToken(request,env,await catalogueOf());if(response)return response;}
|
|
54
|
+
const machine=request.headers.get('authorization')?.startsWith('Bearer homie_studio_');
|
|
40
55
|
const scoped={...env,OAUTH_KV:oauthStorage(env.DB)};
|
|
41
56
|
const localIdentity=await localCaller(request,env);
|
|
42
57
|
const bearer=request.headers.get('authorization')?.startsWith('Bearer homie_owner_');
|
|
43
58
|
const bearerIdentity=bearer?await tokenCaller(request,env):null;
|
|
44
59
|
if(bearer&&!bearerIdentity)return json({error:'connector expired or revoked'},401);
|
|
45
60
|
const api=async(request,env,ctx)=>{
|
|
46
|
-
const
|
|
61
|
+
const rawIdentity=async()=>{if(machine){const fresh=await studioCaller(request,env,await catalogueOf());if(!fresh)throw new Error('Studio authorization revoked');return fresh;}if(bearer){const fresh=await tokenCaller(request,env);if(!fresh)throw new Error('connector revoked');return fresh;}return localIdentity ?? currentCaller(env,ctx.props);};
|
|
62
|
+
const identity=async()=>({...await rawIdentity(),clientAddressKey:`public:${request.headers.get('cf-connecting-ip')??'local'}`});
|
|
47
63
|
let caller;
|
|
48
64
|
try{caller=await identity();}catch{return new Response(JSON.stringify({error:'connection revoked'}),{status:401,headers:{'content-type':'application/json','cache-control':'no-store','www-authenticate':`Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource/mcp", error="invalid_token"`}});}
|
|
49
65
|
if(caller.tools){
|
|
@@ -74,7 +90,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
74
90
|
}
|
|
75
91
|
const rate=async fresh=>{
|
|
76
92
|
const limit=callsPerMinute(cat);
|
|
77
|
-
if(!await rateLimit(env,fresh
|
|
93
|
+
if(!await rateLimit(env,callerLimitKey(fresh),limit))throw new Error('Rate limit reached');
|
|
78
94
|
};
|
|
79
95
|
const read=async(name,handler)=>{
|
|
80
96
|
let outcome='error';
|
|
@@ -144,7 +160,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
144
160
|
if(tool.protocol) {const result=await tool.handler(args,{...context,paymentExtra:extra});outcome=result.isError?'error':'ok';return result;}
|
|
145
161
|
const value=await (await import('./app-tools.mjs')).invokeAppTool(tool,args,{...context,request});
|
|
146
162
|
const text=JSON.stringify({kind:'untrusted-tool-data',tool:tool.name,data:value});
|
|
147
|
-
outcome='ok';return {content:[{type:'text',text}]};
|
|
163
|
+
outcome='ok';return {structuredContent:{kind:'untrusted-tool-data',tool:tool.name,data:value??null},content:[{type:'text',text}]};
|
|
148
164
|
}catch(error){if(error?.code===-32042)throw error;return {isError:true,content:[{type:'text',text:outcome==='denied'||outcome==='limited'||error?.name==='StudioOperationError'?error.message:(freshErrorMessage(error,env,caller))}]};}
|
|
149
165
|
finally{
|
|
150
166
|
try { await audit(env,caller,tool.name,outcome); }
|
|
@@ -178,6 +194,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
178
194
|
if(localIdentity||bearerIdentity)return api(request,scoped,ctx);
|
|
179
195
|
const provider=new OAuthProvider({
|
|
180
196
|
apiRoute:'/mcp',apiHandler:{fetch:api},
|
|
197
|
+
resolveExternalToken:async({request,env})=>(await studioCaller(request,env,await catalogueOf()))?{props:{},audience:origin+'/mcp'}:null,
|
|
181
198
|
authorizeEndpoint:'/oauth/authorize',tokenEndpoint:'/oauth/token',clientRegistrationEndpoint:'/oauth/register',
|
|
182
199
|
resourceMetadata:{resource:origin+'/mcp',authorization_servers:[origin]},
|
|
183
200
|
scopesSupported:['studio','offline_access'],requiredScopes:['studio'],
|
|
@@ -209,7 +226,14 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
209
226
|
const owner=await isOwner(req,env);
|
|
210
227
|
if(req.method==='POST'){
|
|
211
228
|
if(!sameOrigin(req,u))return json({error:'origin refused'},403);
|
|
212
|
-
const f=await req.formData();
|
|
229
|
+
const f=await req.formData();
|
|
230
|
+
if(f.has('studioOrigin')){
|
|
231
|
+
const studio=String(f.get('studioOrigin'));
|
|
232
|
+
if(!owner||!studioRegistration(await catalogueOf(),studio))return json({error:'studio policy refused'},403);
|
|
233
|
+
await setStudioPolicy(env,studio,f.get('allow')==='true');
|
|
234
|
+
return Response.redirect(origin+u.pathname,303);
|
|
235
|
+
}
|
|
236
|
+
const id=String(f.get('connection')??'');
|
|
213
237
|
const connection=await env.DB.prepare('SELECT * FROM mcp_connections WHERE id=?1').bind(id).first();
|
|
214
238
|
if(connection&&(owner||connection.person===person)){
|
|
215
239
|
// Local denial takes effect first, even if upstream token cleanup must be retried.
|
|
@@ -224,9 +248,15 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
224
248
|
}
|
|
225
249
|
const page=Math.max(0,Math.min(100,Number.parseInt(u.searchParams.get('page')??'0',10)||0)),offset=page*100;
|
|
226
250
|
const rows=(await env.DB.prepare(owner?'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person ORDER BY c.created DESC LIMIT 100 OFFSET ?1':'SELECT c.*,p.name FROM mcp_connections c LEFT JOIN players p ON p.id=c.person WHERE c.person=?1 ORDER BY c.created DESC LIMIT 100 OFFSET ?2').bind(...(owner?[offset]:[person,offset])).all()).results;
|
|
227
|
-
const events=owner?(await env.DB.prepare(
|
|
251
|
+
const events=owner?(await env.DB.prepare("SELECT a.*,p.name FROM mcp_audit a LEFT JOIN players p ON p.id=a.person AND (a.client IS NULL OR a.client NOT LIKE 'https://%') ORDER BY a.id DESC LIMIT 100 OFFSET ?1").bind(offset).all()).results:[];
|
|
228
252
|
const names=new Map(await Promise.all([...new Set([...rows,...events].map(r=>r.client).filter(Boolean))].map(async id=>[id,(await oauth.lookupClient(id).catch(()=>null))?.clientName??id])));
|
|
229
|
-
|
|
253
|
+
const studioRows=[];
|
|
254
|
+
if(owner)for(const rule of Object.values((await catalogueOf()).studio?.mcp?.studios?.inbound??{})){
|
|
255
|
+
let peer;try{peer=studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true});}catch{continue;}
|
|
256
|
+
const policy=await oauthStorage(env.DB).get('studio-policy:'+peer,'json');
|
|
257
|
+
studioRows.push(`<li>${esc(peer)} · ${policy?.allow?'Allowed':'Denied'}<form method="post"><input type="hidden" name="studioOrigin" value="${esc(peer)}"><input type="hidden" name="allow" value="${policy?.allow?'false':'true'}"><button>${policy?.allow?'Deny':'Allow'}</button></form></li>`);
|
|
258
|
+
}
|
|
259
|
+
return html(`${owner?'<h2>Studio connections</h2><p>Denied by default. Allow only origins you trust for their declared tools and events.</p><ul>'+studioRows.join('')+'</ul>':''}<h1>Your studio's AI connections</h1><p>Signed-in MCP address: <code>${esc(origin)}/mcp/auth</code> · Public: <code>${esc(origin)}/mcp</code></p><ul>${rows.map(r=>`<li>${esc(r.name??(r.person.startsWith('office-')?'Owner browser':r.person))} · ${esc(names.get(r.client))} · ${r.revoked?'Revoked':`<form method="post"><input type="hidden" name="connection" value="${esc(r.id)}"><button>Revoke</button></form>`}</li>`).join('')}</ul><h2>Recent tool calls</h2><ul>${events.map(r=>`<li>${esc(new Date(r.at).toISOString())} · ${esc(r.client?.startsWith('https://')?'Asserted handle: '+(r.person??'(none)'):(r.name??(r.person?.startsWith('office-')?'Owner browser':r.person)??'Public'))} · ${esc(names.get(r.client)??'Anonymous')} · ${esc(r.tool)} · ${esc(r.outcome)}</li>`).join('')}</ul><nav>${page?`<a href="?page=${page-1}">Newer</a>`:''}${rows.length===100||events.length===100?`<a href="?page=${page+1}">Older</a>`:''}</nav>`);
|
|
230
260
|
}
|
|
231
261
|
return json({error:'not found'},404);
|
|
232
262
|
}},
|
|
@@ -235,5 +265,10 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
235
265
|
// Public tools remain available without an account. Clients can discover OAuth metadata directly.
|
|
236
266
|
return api(request,scoped,ctx);
|
|
237
267
|
}
|
|
238
|
-
|
|
268
|
+
const response=await provider.fetch(request,scoped,ctx);
|
|
269
|
+
if(path==='/.well-known/oauth-authorization-server'&&response.ok){
|
|
270
|
+
const metadata=await response.json();
|
|
271
|
+
return json({...metadata,grant_types_supported:[...new Set([...(metadata.grant_types_supported??[]),'client_credentials'])],token_endpoint_auth_methods_supported:[...new Set([...(metadata.token_endpoint_auth_methods_supported??[]),'private_key_jwt'])]});
|
|
272
|
+
}
|
|
273
|
+
return response;
|
|
239
274
|
}
|
package/worker/room.mjs
CHANGED
|
@@ -272,6 +272,7 @@ export class NetRoom {
|
|
|
272
272
|
this.lastSnap = snap;
|
|
273
273
|
if (!c) { const sentAt = (this.snapAt ??= new Map()); sentAt.set(snap.k, now); if (sentAt.size > 128) sentAt.delete(sentAt.keys().next().value); }
|
|
274
274
|
if (c) c.lastSnapAt = now;
|
|
275
|
+
this.retryBrowserSeats(c);
|
|
275
276
|
this.stats.snaps += 1;
|
|
276
277
|
this.snapTimes.push(now);
|
|
277
278
|
while (this.snapTimes.length && this.snapTimes[0] < now - 1000) this.snapTimes.shift();
|
|
@@ -388,6 +389,34 @@ export class NetRoom {
|
|
|
388
389
|
this.tellWatchers();
|
|
389
390
|
}
|
|
390
391
|
|
|
392
|
+
/** A browser can hear joins before its world exists. Its roster acknowledges
|
|
393
|
+
* bodies, so re-announce missing connected seats until it acknowledges them.
|
|
394
|
+
* Use the existing join protocol for games bundling older netplay clients.
|
|
395
|
+
* Snapshot traffic retries even if the boot roster was published too early;
|
|
396
|
+
* retries back off from 1 to 30 seconds and stop after eight attempts.
|
|
397
|
+
* An exhausted peer is logged once; retries are never sent to replicas.
|
|
398
|
+
*/
|
|
399
|
+
retryBrowserSeats(host) {
|
|
400
|
+
if (!host || this.server || this.rules || host.id !== this.hostId || !this.lastRoster) return;
|
|
401
|
+
const seated = new Set(this.lastRoster.filter(s => !s.bot || s.agent).flatMap(s => [s.seat, s.agent?.seat]));
|
|
402
|
+
const now = this.now();
|
|
403
|
+
for (const peer of this.live()) {
|
|
404
|
+
if (peer.id === host.id || peer.seat === null || peer.watch) continue;
|
|
405
|
+
if (seated.has(peer.seat)) { peer.seatRetry = null; continue; }
|
|
406
|
+
const retry = peer.seatRetry?.host === host.id ? peer.seatRetry : null;
|
|
407
|
+
if (retry && now - retry.at < Math.min(1000 * 2 ** (retry.tries - 1), 30000)) continue;
|
|
408
|
+
if (retry?.tries >= 8) {
|
|
409
|
+
if (!retry.exhausted) {
|
|
410
|
+
retry.exhausted = true;
|
|
411
|
+
this.log({ ev: 'seat-retry-exhausted', room: this.code, id: peer.id, seat: peer.seat, host: host.id, tries: retry.tries });
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
peer.seatRetry = { host: host.id, at: now, tries: (retry?.tries ?? 0) + 1 };
|
|
416
|
+
this.send(host, { t: 'join', peer: this.peer(peer) });
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
391
420
|
hostCaps(c, m) {
|
|
392
421
|
// The host's game reads the dial and/or moves agents' bodies (section 17): the office and the shell say so.
|
|
393
422
|
if (!Array.isArray(m.caps)) return;
|
package/worker/routes.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export default async function route(request, env, ctx) {
|
|
|
56
56
|
|
|
57
57
|
if (path === '/_studio/office/functions') return (await import('./functions.mjs')).functionsRoute(request,env,await getAll());
|
|
58
58
|
|
|
59
|
-
if (path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
59
|
+
if (path === '/.well-known/jwks.json' || path === '/mcp' || path === '/mcp/auth' || path.startsWith('/hooks/tools/') || path.startsWith('/hooks/studios/') || path.startsWith('/oauth/') || path.startsWith('/.well-known/oauth-') || path === '/_studio/office/connections') {
|
|
60
60
|
const { remoteMcp } = await import('./mcp.mjs');
|
|
61
61
|
return remoteMcp(request, env, ctx, { catalogueOf: getAll, definitions: await studioTools() });
|
|
62
62
|
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import {decode,originKey,verifyBytes} from './studios-keys.mjs';
|
|
2
|
+
import {oauthStorage,audit} from './mcp-store.mjs';
|
|
3
|
+
import {studioOrigin} from './studios-client.mjs';
|
|
4
|
+
const hash=async text=>Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256',new TextEncoder().encode(text))),b=>b.toString(16).padStart(2,'0')).join('');
|
|
5
|
+
const json=(data,status=200)=>Response.json(data,{status,headers:{'cache-control':'no-store','pragma':'no-cache',...(status===401?{'www-authenticate':'Bearer'}:{})}});
|
|
6
|
+
export async function boundedBody(request,maxBytes=65536) {
|
|
7
|
+
const reader=request.body?.getReader();if(!reader)return '';
|
|
8
|
+
const chunks=[];let bytes=0;
|
|
9
|
+
try{while(true){const {done,value}=await reader.read();if(done)break;bytes+=value.byteLength;if(bytes>maxBytes)throw new Error('Request size exceeded');chunks.push(value);}}
|
|
10
|
+
catch(error){await reader.cancel();throw error;}finally{reader.releaseLock();}
|
|
11
|
+
const body=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){body.set(chunk,offset);offset+=chunk.length;}return new TextDecoder('utf-8',{fatal:true}).decode(body);
|
|
12
|
+
}
|
|
13
|
+
export function studioRegistration(cat,origin){
|
|
14
|
+
for(const rule of Object.values(cat.studio?.mcp?.studios?.inbound??{})){
|
|
15
|
+
try{if(studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true})===origin&&rule.enabled!==false)return rule;}catch{/* Invalid configuration is denied. */}
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export async function setStudioPolicy(env,origin,allow){
|
|
20
|
+
origin=studioOrigin(origin,{allowPrivate:true});
|
|
21
|
+
await oauthStorage(env.DB).put('studio-policy:'+origin,JSON.stringify({allow:allow===true}));
|
|
22
|
+
}
|
|
23
|
+
export async function studioPolicy(env,cat,origin){
|
|
24
|
+
const rule=studioRegistration(cat,origin);
|
|
25
|
+
return rule&&(await oauthStorage(env.DB).get('studio-policy:'+origin,'json'))?.allow===true?rule:null;
|
|
26
|
+
}
|
|
27
|
+
/** RFC 7523 client authentication. Only the claimed origin's HTTPS JWKS can prove it. */
|
|
28
|
+
export async function studioToken(request,env,cat,options={}) {
|
|
29
|
+
if(request.method!=='POST')return null;
|
|
30
|
+
let form;try{form=new URLSearchParams(await boundedBody(request.clone(),8192));}catch{return json({error:'invalid_request'},400);}
|
|
31
|
+
if(form.get('grant_type')!=='client_credentials')return null;
|
|
32
|
+
const receiver=new URL(request.url).origin;
|
|
33
|
+
if(!receiver.startsWith('https:')||form.getAll('resource').length!==1||form.get('resource')!==receiver+'/mcp')return json({error:'invalid_target'},400);
|
|
34
|
+
if(form.has('scope')&&form.get('scope')!=='studio')return json({error:'invalid_scope'},400);
|
|
35
|
+
let origin,stage='assertion';
|
|
36
|
+
try{
|
|
37
|
+
for(const name of ['client_assertion','client_assertion_type','client_id'])if(form.getAll(name).length!==1)throw Error();
|
|
38
|
+
if(form.get('client_assertion_type')!=='urn:ietf:params:oauth:client-assertion-type:jwt-bearer')throw Error();
|
|
39
|
+
const parts=form.get('client_assertion').split('.');if(parts.length!==3)throw Error();
|
|
40
|
+
const header=decode(parts[0]),claims=decode(parts[1]);origin=claims.iss;
|
|
41
|
+
stage='policy';
|
|
42
|
+
const rule=await studioPolicy(env,cat,origin);if(!rule)throw Error();
|
|
43
|
+
stage='claims';
|
|
44
|
+
if(studioOrigin(origin,{allowPrivate:rule.allowPrivate===true})!==origin||form.get('client_id')!==origin||claims.sub!==origin||claims.aud!==receiver+'/oauth/token'||header.alg!=='EdDSA'||header.jku||header.jwk||header.crit)throw Error();
|
|
45
|
+
const now=Math.floor(Date.now()/1000);
|
|
46
|
+
if(!Number.isSafeInteger(claims.exp)||!Number.isSafeInteger(claims.iat)||claims.iat>now+30||claims.iat<now-300||claims.exp<=now||claims.exp>claims.iat+300||claims.nbf!==undefined&&(!Number.isSafeInteger(claims.nbf)||claims.nbf>now)||typeof claims.jti!=='string'||! /^[A-Za-z0-9_-]{1,128}$/.test(claims.jti))throw Error();
|
|
47
|
+
stage='key discovery';
|
|
48
|
+
const key=await originKey(env,origin,header.kid,{...options,allowPrivate:rule.allowPrivate===true});
|
|
49
|
+
stage='signature';
|
|
50
|
+
if(!await verifyBytes(key,parts[2],parts.slice(0,2).join('.')))throw Error();
|
|
51
|
+
stage='replay';
|
|
52
|
+
const used=await env.DB.prepare('INSERT OR IGNORE INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) RETURNING key').bind('studio-jti:'+await hash(JSON.stringify([origin,claims.jti])),'1',claims.exp).first();if(!used)throw Error();
|
|
53
|
+
}catch{
|
|
54
|
+
await audit(env,{client:typeof origin==='string'?origin.slice(0,2048):null},'studio-auth','denied:'+stage);
|
|
55
|
+
return json({error:'invalid_client',error_description:'Studio authentication failed'},401);
|
|
56
|
+
}
|
|
57
|
+
const token='homie_studio_'+Array.from(crypto.getRandomValues(new Uint8Array(32)),b=>b.toString(16).padStart(2,'0')).join('');
|
|
58
|
+
const record={origin,resource:receiver+'/mcp',expires:Math.floor(Date.now()/1000)+300};
|
|
59
|
+
await oauthStorage(env.DB).put('studio-token:'+await hash(token),JSON.stringify(record),{expiration:record.expires});
|
|
60
|
+
return json({access_token:token,token_type:'Bearer',expires_in:300,scope:'studio'});
|
|
61
|
+
}
|
|
62
|
+
/** Live D1 policy is re-read on every dispatch; handles only separate booking ownership. */
|
|
63
|
+
export async function studioCaller(request,env,cat) {
|
|
64
|
+
const token=/^Bearer (homie_studio_[a-f0-9]{64})$/.exec(request.headers.get('authorization')??'')?.[1];if(!token)return null;
|
|
65
|
+
const record=await oauthStorage(env.DB).get('studio-token:'+await hash(token),'json');
|
|
66
|
+
if(!record||record.expires<=Date.now()/1000||record.resource!==new URL(request.url).origin+'/mcp')return null;
|
|
67
|
+
const rule=await studioPolicy(env,cat,record.origin);if(!rule)return null;
|
|
68
|
+
let assertedHandle;try{const raw=request.headers.get('x-studio-asserted-handle');if(raw!==null){assertedHandle=decodeURIComponent(raw);if(!/^[A-Za-z0-9._@-]{1,128}$/.test(assertedHandle))return null;}}catch{return null;}
|
|
69
|
+
return {id:'studio:'+await hash(JSON.stringify([record.origin,assertedHandle??null])),owner:false,roles:{},client:record.origin,tools:rule.tools??[],
|
|
70
|
+
studio:{origin:record.origin,verified:true,method:'private_key_jwt'},...(assertedHandle===undefined?{}:{actingFor:{assertedHandle,verified:false,assertedBy:record.origin}})};
|
|
71
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import {signAssertion} from './studios-keys.mjs';
|
|
2
|
+
import {Client, StreamableHTTPClientTransport, PrivateKeyJwtProvider} from 'mcp-studio-client';
|
|
3
|
+
export function studioOrigin(value,{allowPrivate=false}={}) {
|
|
4
|
+
const url=new URL(value);
|
|
5
|
+
if(url.protocol!=='https:'||url.username||url.password||url.pathname!=='/'||url.search||url.hash)throw new Error('Studio requires a bare HTTPS origin');
|
|
6
|
+
const host=url.hostname.toLowerCase().replace(/\.$/,'');
|
|
7
|
+
if(!allowPrivate&&(url.port||host.includes(':')||/^[0-9.]+$/.test(host)||!host.includes('.')||/(^|\.)(localhost|local|internal)$/.test(host)))throw new Error('Studio requires a public DNS origin on port 443');
|
|
8
|
+
return url.origin;
|
|
9
|
+
}
|
|
10
|
+
/** Bound bytes before SDK parsing; the SDK owns JSON-RPC, SSE and OAuth. */
|
|
11
|
+
export function boundedStudioFetch(origin,signal,{maxBytes=1024*1024,fetchFn=globalThis.fetch.bind(globalThis)}={}) {
|
|
12
|
+
return async(input,init)=>{
|
|
13
|
+
const request=new Request(input,init),url=new URL(request.url);
|
|
14
|
+
if(url.origin!==origin||url.username||url.password)throw new Error('Studio request escaped its origin');
|
|
15
|
+
const response=await fetchFn(new Request(request,{redirect:'manual',signal:AbortSignal.any([signal,request.signal])}));
|
|
16
|
+
if(response.status>=300&&response.status<400){await response.body?.cancel();throw new Error('Studio redirects refused');}
|
|
17
|
+
if(Number(response.headers.get('content-length'))>maxBytes){await response.body?.cancel();throw new Error('Studio response size exceeded');}
|
|
18
|
+
if(!response.body)return response;
|
|
19
|
+
let bytes=0;
|
|
20
|
+
const body=response.body.pipeThrough(new TransformStream({transform(chunk,controller){bytes+=chunk.byteLength;if(bytes>maxBytes)throw new Error('Studio response size exceeded');controller.enqueue(chunk);}}));
|
|
21
|
+
return new Response(body,{status:response.status,statusText:response.statusText,headers:response.headers});
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
export function studioResult(result,source={origin:null,authenticated:false}) {
|
|
25
|
+
let data=result.structuredContent;
|
|
26
|
+
if(data===undefined&&result.content?.length===1&&result.content[0].type==='text'){
|
|
27
|
+
try{const parsed=JSON.parse(result.content[0].text);if(parsed?.kind==='untrusted-tool-data')data=parsed;}catch{/* Ordinary MCP text remains content. */}
|
|
28
|
+
}
|
|
29
|
+
if(data?.kind==='untrusted-tool-data'&&Object.hasOwn(data,'data'))data=data.data;
|
|
30
|
+
return {source,untrusted:true,isError:result.isError===true,...(data===undefined?{}:{data}),content:result.content??[]};
|
|
31
|
+
}
|
|
32
|
+
export function outboundRules(cat) {
|
|
33
|
+
const rules=new Map();
|
|
34
|
+
for(const [key,rule] of Object.entries(cat.studio?.mcp?.studios?.outbound??{})) {
|
|
35
|
+
const origin=studioOrigin(key,{allowPrivate:rule.allowPrivate===true});
|
|
36
|
+
if(rules.has(origin)||rule.origin!==undefined&&studioOrigin(rule.origin,{allowPrivate:rule.allowPrivate===true})!==origin)throw new Error('Studio configuration origin mismatch');
|
|
37
|
+
rules.set(origin,rule);
|
|
38
|
+
}
|
|
39
|
+
return rules;
|
|
40
|
+
}
|
|
41
|
+
export function studiosClient(env,cat,definition,{fetchFn=globalThis.fetch.bind(globalThis),origin:ownOrigin}={}) {
|
|
42
|
+
const providers=new Map(),discovery=new Map();
|
|
43
|
+
const rules=outboundRules(cat);
|
|
44
|
+
const run=async(origin,operation,options={})=>{
|
|
45
|
+
const candidate=new URL(origin).origin,rule=rules.get(candidate);
|
|
46
|
+
origin=studioOrigin(origin,{allowPrivate:rule?.allowPrivate===true});
|
|
47
|
+
if(!rule)throw new Error('Studio destination is not configured');
|
|
48
|
+
if(rule&&!rule.tools?.includes(definition.name))throw new Error('Studio connection not delegated to this tool');
|
|
49
|
+
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(new Error('Studio request timed out')),10000);
|
|
50
|
+
const client=new Client({name:'homie-studio',version:'1'});
|
|
51
|
+
try{
|
|
52
|
+
const authenticated=rule.anonymous!==true;
|
|
53
|
+
let authProvider;
|
|
54
|
+
if(authenticated){
|
|
55
|
+
if(!ownOrigin||!env.STUDIO_SIGNING_KEY)throw new Error('Studio signing key unavailable');
|
|
56
|
+
authProvider=providers.get(origin);
|
|
57
|
+
if(!authProvider){
|
|
58
|
+
authProvider=new PrivateKeyJwtProvider({clientId:ownOrigin,privateKey:{},algorithm:'EdDSA',scope:'studio',expectedIssuer:origin});
|
|
59
|
+
authProvider.addClientAuthentication=async(_headers,params,url)=>{
|
|
60
|
+
if(String(url)!==origin+'/oauth/token')throw new Error('Unexpected studio token endpoint');
|
|
61
|
+
params.set('client_id',ownOrigin);
|
|
62
|
+
params.set('client_assertion_type','urn:ietf:params:oauth:client-assertion-type:jwt-bearer');
|
|
63
|
+
params.set('client_assertion',await signAssertion(env,ownOrigin,String(url)));
|
|
64
|
+
};
|
|
65
|
+
providers.set(origin,authProvider);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
const cachedFetch=async(request)=>{
|
|
69
|
+
const url=new URL(request.url),cacheable=request.method==='GET'&&url.pathname.startsWith('/.well-known/');
|
|
70
|
+
if(cacheable&&discovery.has(request.url))return discovery.get(request.url).clone();
|
|
71
|
+
const response=await fetchFn(request);
|
|
72
|
+
if(cacheable&&response.ok){const bounded=await (await boundedStudioFetch(origin,controller.signal,{fetchFn:async()=>response,maxBytes:32768})(request)).arrayBuffer();if(bounded.byteLength>32768)throw new Error('Discovery size exceeded');const saved=new Response(bounded,{status:response.status,headers:response.headers});discovery.set(request.url,saved);return saved.clone();}
|
|
73
|
+
return response;
|
|
74
|
+
};
|
|
75
|
+
const headers={};
|
|
76
|
+
if(options.assertedHandle!==undefined){
|
|
77
|
+
if(!authenticated||typeof options.assertedHandle!=='string'||! /^[A-Za-z0-9._@-]{1,128}$/.test(options.assertedHandle))throw new Error('Acting-for needs an authenticated studio and a handle of 1–128 characters');
|
|
78
|
+
headers['x-studio-asserted-handle']=encodeURIComponent(options.assertedHandle);
|
|
79
|
+
}
|
|
80
|
+
const transport=new StreamableHTTPClientTransport(new URL(authenticated?'/mcp/auth':'/mcp',origin),{fetch:boundedStudioFetch(origin,controller.signal,{fetchFn:async request=>cachedFetch(request)}),authProvider,requestInit:{headers}});
|
|
81
|
+
await client.connect(transport,{signal:controller.signal,timeout:10000});
|
|
82
|
+
return await operation(client,controller.signal,{origin,authenticated});
|
|
83
|
+
}finally{controller.abort();clearTimeout(timer);await client.close();}
|
|
84
|
+
};
|
|
85
|
+
return Object.freeze({
|
|
86
|
+
send:(origin,name,event)=>import('./studios-events.mjs').then(m=>m.sendStudioEvent(env,cat,ownOrigin,definition,origin,name,event,{fetchFn})),
|
|
87
|
+
listTools:(origin,options)=>run(origin,async(client,signal,source)=>{const page=await client.listTools(options?.cursor?{cursor:options.cursor}:{},{signal,timeout:10000});return {...page,source,untrusted:true,tools:page.tools.map(tool=>({...tool,source,untrusted:true,description:'[Untrusted remote description] '+(tool.description??'')}))};},options),
|
|
88
|
+
call:(origin,tool,args={},options)=>{
|
|
89
|
+
if(new TextEncoder().encode(JSON.stringify(args)).length>65536)return Promise.reject(new Error('Studio arguments size exceeded'));
|
|
90
|
+
return run(origin,async(client,signal,source)=>studioResult(await client.callTool({name:tool,arguments:args},{signal,timeout:10000}),source),options);
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import {boundedBody,studioPolicy} from './studios-auth.mjs';
|
|
2
|
+
import {studioOrigin,outboundRules,boundedStudioFetch} from './studios-client.mjs';
|
|
3
|
+
import {signBytes,originKey,verifyBytes} from './studios-keys.mjs';
|
|
4
|
+
import {publishBusinessEvent,BUSINESS_EVENT_MAX_BYTES} from './business-events.mjs';
|
|
5
|
+
import {syncFunctions} from './functions.mjs';
|
|
6
|
+
export async function signStudioEvent(env,source,name,{id,data},{now=Date.now}={}){
|
|
7
|
+
if(typeof id!=='string'||!/^[A-Za-z0-9_-]{1,128}$/.test(id))throw new Error('Stable event id required');
|
|
8
|
+
const body=JSON.stringify({eventId:id,name,timestamp:new Date(now()).toISOString(),source,data});
|
|
9
|
+
if(new TextEncoder().encode(body).length>BUSINESS_EVENT_MAX_BYTES)throw new Error('Studio event exceeds 8192 bytes');
|
|
10
|
+
const timestamp=String(Math.floor(now()/1000)),{kid,signature}=await signBytes(env,`${id}.${timestamp}.${body}`);
|
|
11
|
+
return {body,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':'v1a,'+signature,'webhook-key-id':kid,'webhook-origin':source}};
|
|
12
|
+
}
|
|
13
|
+
export async function sendStudioEvent(env,cat,source,definition,origin,name,event,options={}) {
|
|
14
|
+
const rule=outboundRules(cat).get(new URL(origin).origin);origin=studioOrigin(origin,{allowPrivate:rule?.allowPrivate===true});
|
|
15
|
+
if(!rule?.tools?.includes(definition.name)||!rule.events?.includes(name)||!/^\/hooks\/studios\/[a-zA-Z0-9_-]+$/.test(rule.eventPath??''))throw new Error('Studio event destination not delegated');
|
|
16
|
+
if(rule.anonymous||!source)throw new Error('Studio event requires origin authentication');
|
|
17
|
+
const signed=await signStudioEvent(env,source,name,event,options);
|
|
18
|
+
const response=await boundedStudioFetch(origin,AbortSignal.timeout(10000),options)(origin+rule.eventPath,{method:'POST',...signed});
|
|
19
|
+
await response.body?.cancel();if(!response.ok)throw new Error('Studio event rejected ('+response.status+')');
|
|
20
|
+
return {id:event.id};
|
|
21
|
+
}
|
|
22
|
+
/** Standard Webhooks v1a: Ed25519 over id.timestamp.exact-body, keys fetched only from configured origin. */
|
|
23
|
+
export async function receiveStudioEvent(request,env,cat,options={}) {
|
|
24
|
+
if(request.method!=='POST')return new Response(null,{status:405});
|
|
25
|
+
const id=new URL(request.url).pathname.slice('/hooks/studios/'.length),entries=cat.studio?.mcp?.studios?.inbound??{};
|
|
26
|
+
const configured=Object.hasOwn(entries,id)?entries[id]:null;
|
|
27
|
+
let origin,rule;try{origin=studioOrigin(configured?.origin,{allowPrivate:configured?.allowPrivate===true});if(configured.enabled!==false&&await studioPolicy(env,cat,origin))rule=configured;}catch{}
|
|
28
|
+
if(!rule)return new Response(null,{status:403});
|
|
29
|
+
let body;try{body=await boundedBody(request,BUSINESS_EVENT_MAX_BYTES);}catch{return new Response('Studio event exceeds 8192 bytes',{status:413});}
|
|
30
|
+
let event;
|
|
31
|
+
try{
|
|
32
|
+
const timestamp=request.headers.get('webhook-timestamp'),eventId=request.headers.get('webhook-id');
|
|
33
|
+
if(!/^\d+$/.test(timestamp??'')||Math.abs(Date.now()/1000-Number(timestamp))>300||!/^[A-Za-z0-9_-]{1,128}$/.test(eventId??'')||request.headers.get('webhook-origin')!==origin)throw Error();
|
|
34
|
+
const key=await originKey(env,origin,request.headers.get('webhook-key-id'),{...options,allowPrivate:rule.allowPrivate===true});
|
|
35
|
+
let valid=false;for(const part of (request.headers.get('webhook-signature')??'').split(' ').slice(0,8)){
|
|
36
|
+
const [version,signature]=part.split(',');if(version==='v1a'&&await verifyBytes(key,signature,`${eventId}.${timestamp}.${body}`)){valid=true;break;}
|
|
37
|
+
}
|
|
38
|
+
if(!valid)throw Error();event=JSON.parse(body);
|
|
39
|
+
}catch{return new Response(null,{status:401});}
|
|
40
|
+
if(!event||typeof event!=='object'||Array.isArray(event)||event.source!==origin||event.eventId!==request.headers.get('webhook-id')||typeof event.name!=='string')return new Response(null,{status:400});
|
|
41
|
+
const feed=Object.hasOwn(rule.events??{},event.name)?rule.events[event.name]:null;
|
|
42
|
+
if(!feed)return new Response(null,{status:403});
|
|
43
|
+
await syncFunctions(env);
|
|
44
|
+
try{await publishBusinessEvent(env,{}, {publish:[feed]},'@studio:'+origin,{feed,id:event.eventId,data:{sender:{origin,verified:true},data:event.data}});}
|
|
45
|
+
catch(error){
|
|
46
|
+
if(/already published/.test(error.message))return new Response(null,{status:409});
|
|
47
|
+
if(/8192 bytes/.test(error.message))return new Response(error.message,{status:413});
|
|
48
|
+
if(/schema|characters/.test(error.message))return new Response(null,{status:400});
|
|
49
|
+
throw error;
|
|
50
|
+
}
|
|
51
|
+
return new Response(null,{status:204});
|
|
52
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {oauthStorage} from './mcp-store.mjs';
|
|
2
|
+
import {studioOrigin,boundedStudioFetch} from './studios-client.mjs';
|
|
3
|
+
const bytes=text=>new TextEncoder().encode(text);
|
|
4
|
+
export const base64=value=>btoa(String.fromCharCode(...new Uint8Array(value)));
|
|
5
|
+
export const unbase64=value=>Uint8Array.from(atob(value),c=>c.charCodeAt(0));
|
|
6
|
+
const encode=value=>base64(bytes(JSON.stringify(value))).replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
|
|
7
|
+
export const decode=value=>JSON.parse(new TextDecoder().decode(unbase64(value.replaceAll('-','+').replaceAll('_','/'))));
|
|
8
|
+
export function publicKeys(env){
|
|
9
|
+
const current=JSON.parse(env.STUDIO_SIGNING_KEY??'null');
|
|
10
|
+
const keys=[current,...JSON.parse(env.STUDIO_PREVIOUS_PUBLIC_KEYS??'[]')].filter(Boolean);
|
|
11
|
+
return {keys:keys.map(({kty,crv,x,kid})=>({kty,crv,x,kid,alg:'EdDSA',use:'sig'}))};
|
|
12
|
+
}
|
|
13
|
+
export async function signBytes(env,text){
|
|
14
|
+
const jwk=JSON.parse(env.STUDIO_SIGNING_KEY??'null');
|
|
15
|
+
if(!jwk?.kid||jwk.crv!=='Ed25519'||!jwk.d)throw new Error('Studio signing key unavailable');
|
|
16
|
+
const key=await crypto.subtle.importKey('jwk',{...jwk,alg:'EdDSA'},'Ed25519',false,['sign']);
|
|
17
|
+
return {kid:jwk.kid,signature:base64(await crypto.subtle.sign('Ed25519',key,bytes(text)))};
|
|
18
|
+
}
|
|
19
|
+
export async function signAssertion(env,origin,audience){
|
|
20
|
+
const jwk=JSON.parse(env.STUDIO_SIGNING_KEY??'null'),now=Math.floor(Date.now()/1000);
|
|
21
|
+
const text=encode({alg:'EdDSA',typ:'JWT',kid:jwk?.kid})+'.'+encode({iss:origin,sub:origin,aud:audience,iat:now,exp:now+60,jti:crypto.randomUUID()});
|
|
22
|
+
return text+'.'+(await signBytes(env,text)).signature.replaceAll('+','-').replaceAll('/','_').replace(/=+$/,'');
|
|
23
|
+
}
|
|
24
|
+
/** D1 cache and atomic per-origin fetch claim bound failures and unknown kids across requests. */
|
|
25
|
+
export async function originKey(env,origin,kid,{fetchFn=globalThis.fetch.bind(globalThis),allowPrivate=false}={}){
|
|
26
|
+
origin=studioOrigin(origin,{allowPrivate});
|
|
27
|
+
if(typeof kid!=='string'||! /^[A-Za-z0-9_-]{1,128}$/.test(kid))throw new Error('Invalid key id');
|
|
28
|
+
const store=oauthStorage(env.DB),cacheKey='studio-jwks:'+origin,now=Date.now();
|
|
29
|
+
let cached=await store.get(cacheKey,'json');
|
|
30
|
+
if(!cached||cached.until<=now||!cached.keys.some(k=>k.kid===kid)&&now-cached.at>=60000){
|
|
31
|
+
// Claim before network I/O: concurrent misses cannot each fetch. Milliseconds
|
|
32
|
+
// in value enforce a full 60 seconds; expires is the storage cleanup bound.
|
|
33
|
+
const claim=await env.DB.prepare("INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3) ON CONFLICT(key) DO UPDATE SET value=excluded.value,expires=excluded.expires WHERE CAST(mcp_kv.value AS INTEGER)<=?4 RETURNING key")
|
|
34
|
+
.bind('studio-jwks-fetch:'+origin,String(now+60000),Math.ceil((now+60000)/1000),now).first();
|
|
35
|
+
if(claim){
|
|
36
|
+
try{
|
|
37
|
+
const fetch=boundedStudioFetch(origin,AbortSignal.timeout(5000),{fetchFn,maxBytes:32768});
|
|
38
|
+
const response=await fetch(origin+'/.well-known/jwks.json');if(!response.ok)throw new Error('Studio keys unavailable');
|
|
39
|
+
const jwks=await response.json();
|
|
40
|
+
if(!Array.isArray(jwks?.keys)||jwks.keys.length>8||jwks.keys.some(k=>!k||typeof k!=='object'||k.d))throw new Error('Invalid studio keys');
|
|
41
|
+
cached={keys:jwks.keys,at:now,until:now+300000};
|
|
42
|
+
}catch{
|
|
43
|
+
// Preserve still-valid known keys when an unknown-kid refresh fails.
|
|
44
|
+
cached={keys:cached?.until>now?cached.keys:[],at:now,until:Math.max(cached?.until??0,now+60000)};
|
|
45
|
+
}
|
|
46
|
+
await store.put(cacheKey,JSON.stringify(cached),{expiration:Math.ceil(cached.until/1000)});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if(!cached||cached.until<=now)throw new Error('Studio keys unavailable');
|
|
50
|
+
const matches=cached.keys.filter(k=>k.kid===kid&&k.kty==='OKP'&&k.crv==='Ed25519'&&(!k.alg||k.alg==='EdDSA')&&(!k.use||k.use==='sig'));
|
|
51
|
+
if(matches.length!==1)throw new Error('Unknown studio key');
|
|
52
|
+
return crypto.subtle.importKey('jwk',matches[0],'Ed25519',false,['verify']);
|
|
53
|
+
}
|
|
54
|
+
export async function verifyBytes(key,signature,text){return crypto.subtle.verify('Ed25519',key,unbase64(signature.replaceAll('-','+').replaceAll('_','/')),bytes(text));}
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.57.0';
|