@homie-rocks/studio 0.51.3 → 0.51.5
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 +45 -0
- package/README.md +10 -29
- package/apps/APPS.md +1 -1
- package/bin/homie-studio.mjs +28 -30
- package/docs/mcp-events.md +68 -0
- package/guides/app/SKILL.md +25 -1
- package/guides/art/SKILL.md +68 -140
- package/guides/art/scripts/art.mjs +6 -79
- package/guides/models/SKILL.md +73 -177
- package/guides/models/references/RIGHTS.md +1 -1
- package/guides/models/scripts/models.mjs +7 -288
- package/guides/music/SKILL.md +74 -183
- package/guides/music/references/COMPOSITION.md +21 -26
- package/guides/music/references/GAME-AUDIO.md +3 -3
- package/guides/music/references/RIGHTS.md +12 -15
- package/guides/music/scripts/music.mjs +11 -202
- package/guides/office/SKILL.md +24 -0
- package/guides/parts/SKILL.md +1 -1
- package/guides/servers/SKILL.md +24 -0
- package/guides/shop/SKILL.md +68 -77
- package/guides/sound/SKILL.md +70 -1
- package/guides/sound/scripts/player/sound.js +233 -0
- package/guides/studio-setup/SKILL.md +11 -27
- package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
- package/guides/style/SKILL.md +66 -3
- package/guides/video/SKILL.md +83 -0
- package/guides/video/references/DELIVERY.md +73 -0
- package/guides/video/references/HONESTY.md +40 -0
- package/guides/video/references/METHOD.md +144 -0
- package/guides/video/references/RECORD.md +137 -0
- package/guides/video/references/TRAILER.md +154 -0
- package/guides/video/references/examples/site-walkthrough.json +14 -0
- package/guides/video/references/examples/studio-play-phone.json +15 -0
- package/guides/video/references/examples/studio-play.json +17 -0
- package/guides/video/scripts/capture-game.mjs +175 -0
- package/guides/video/scripts/clock.js +136 -0
- package/guides/video/scripts/drawover/film.js +58 -0
- package/guides/video/scripts/drawover/index.html +9 -0
- package/guides/video/scripts/drawover/look.js +115 -0
- package/guides/video/scripts/drawover/shots.json +12 -0
- package/guides/video/scripts/lib/browser.mjs +87 -0
- package/guides/video/scripts/lib/frames.mjs +170 -0
- package/guides/video/scripts/lib/remix.mjs +159 -0
- package/guides/video/scripts/qa.mjs +127 -0
- package/guides/video/scripts/record-fixed.mjs +403 -0
- package/guides/video/scripts/record-page.mjs +477 -0
- package/guides/video/scripts/tap.js +108 -0
- package/guides/video/scripts/video.mjs +888 -0
- package/lib/build.mjs +12 -9
- package/lib/cloudflare.mjs +6 -11
- package/lib/dev.mjs +1 -2
- package/lib/doctor.mjs +10 -54
- package/lib/functions-build.mjs +8 -1
- package/lib/mcp-tools.mjs +42 -21
- package/lib/mcp.mjs +1 -1
- package/lib/media-import.mjs +41 -0
- package/lib/media-workflows.mjs +13 -0
- package/lib/official-media.mjs +15 -0
- package/lib/rules-build.mjs +13 -5
- package/lib/scaffold.mjs +2 -2
- package/lib/shop-links.mjs +54 -18
- package/lib/shop-listen.mjs +43 -0
- package/lib/shop-local.mjs +1 -1
- package/lib/shop-mcp.mjs +58 -0
- package/lib/stripe-connect.mjs +36 -15
- package/lib/template-history.json +87 -0
- package/lib/upgrade-media.mjs +13 -0
- package/lib/upgrade.mjs +10 -0
- package/package.json +1 -1
- package/parts/PARTS.md +1 -1
- package/rules/view.ts +4 -0
- package/shop/SHOP.md +66 -34
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +12 -3
- package/worker/event-webhooks.mjs +14 -9
- package/worker/functions.mjs +11 -6
- package/worker/index.mjs +5 -1
- package/worker/mcp-event-streams.mjs +58 -0
- package/worker/mcp-events-wire.mjs +41 -0
- package/worker/mcp-events.mjs +132 -0
- package/worker/mcp.mjs +4 -1
- package/worker/purchase-reconciliation.mjs +16 -1
- package/worker/shop-subscriptions.mjs +2 -0
- package/worker/shop.mjs +7 -5
- package/worker/stripe-ownership.mjs +7 -0
- package/worker/version.mjs +1 -1
- package/guides/models/references/models.json +0 -175
- package/guides/music/scripts/lib/eleven.mjs +0 -260
- package/lib/projects-env.mjs +0 -68
- package/lib/projects.mjs +0 -311
- package/lib/provider-key.mjs +0 -21
package/rules/view.ts
CHANGED
|
@@ -728,6 +728,10 @@ export function openRoom<R = unknown>(opts: OpenRoomOptions = {}): Room<R> {
|
|
|
728
728
|
while (stepped < t) {
|
|
729
729
|
stepped += 1;
|
|
730
730
|
step(stepped, stepped === t);
|
|
731
|
+
// fire() can read/cache the own entity before this pump advances it.
|
|
732
|
+
// A renderer later in the same callback must see the completed step,
|
|
733
|
+
// not hold that effect's old pose until the next (possibly early) frame.
|
|
734
|
+
presentedEntity = null;
|
|
731
735
|
if (stepped % sendEvery === sendEvery - 1) flush(now, stepped);
|
|
732
736
|
}
|
|
733
737
|
}
|
package/shop/SHOP.md
CHANGED
|
@@ -101,26 +101,68 @@ One order stores every line and its item snapshot. `/api/shop/mine` lists the li
|
|
|
101
101
|
|
|
102
102
|
`homie-studio shop init --supporter` writes an example item and `SELLING.md`, without a policy, cap or refund
|
|
103
103
|
window. `shop check` validates settings; the build uses the same validation.
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
`
|
|
123
|
-
|
|
104
|
+
## Default: Stripe's official plugin
|
|
105
|
+
|
|
106
|
+
Use Stripe's official plugin, authenticated with its browser OAuth approval, for Products,
|
|
107
|
+
Prices, Payment Links, menu links, per-slot booking links, monthly giving and the customer
|
|
108
|
+
portal. Install with `stripe agent setup` if needed; the MCP URL is
|
|
109
|
+
`https://mcp.stripe.com`. Current tools are `stripe_api_search`, `stripe_api_details`,
|
|
110
|
+
`stripe_api_read` and `stripe_api_write`. Discover the operation and its schema before
|
|
111
|
+
calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
|
|
112
|
+
|
|
113
|
+
1. Create/check `shop.json`. Read the app's published slot records and supply the complete
|
|
114
|
+
slot manifest described below. Select the owner's account and sandbox in Stripe's plugin;
|
|
115
|
+
read `/v1/account` to record its account ID. Live mode only after the owner requests it.
|
|
116
|
+
2. Call Homie's `stripe_shop_plan` with `account`, `mode: "test"`, `slots` and
|
|
117
|
+
`observations: []`. CLI equivalent: `homie-studio shop plan --have plan-input.json`.
|
|
118
|
+
That JSON file contains the same fields. A returned `next` is the exact operation:
|
|
119
|
+
method, API path, parameters, API version and (for creates) idempotency key. Updates set desired fields; read before retrying. Metadata
|
|
120
|
+
scopes objects to this studio and origin; prices and links have content revisions.
|
|
121
|
+
3. Perform each `next` through Stripe's official MCP. For GET, append
|
|
122
|
+
`{ "path": "…", "params": {…}, "body": <Stripe response> }` to `observations`
|
|
123
|
+
and plan again. Follow every pagination request. After a write, discard observations
|
|
124
|
+
and read fresh. Replanning resolves real Stripe IDs; never invent IDs or substitute
|
|
125
|
+
a different price. Reuse the supplied idempotency key where the tool supports it;
|
|
126
|
+
otherwise read before retrying and match the stable product ID/revision metadata.
|
|
127
|
+
4. When no writes remain, use fresh reads with `stripe_shop_plan` `verify: true`
|
|
128
|
+
(CLI `homie-studio shop verify --have plan-input.json`). It checks the catalog,
|
|
129
|
+
actual link fields and line items, menus, slot limits and portal settings against
|
|
130
|
+
shop.json and the slot manifest. A mismatch blocks verification. It saves only public
|
|
131
|
+
configuration to `.studio/stripe-mcp.json`; local test configuration goes to `.dev.vars`.
|
|
132
|
+
5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
|
|
133
|
+
Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
|
|
134
|
+
checks the same account as the MCP, and installs the signing secret and verified
|
|
135
|
+
links through Wrangler stdin. The CLI needs one separate browser approval if its
|
|
136
|
+
studio profile is not already connected. MCP approval cannot be reused or exported.
|
|
137
|
+
Cloudflare authorization and an existing deployment are prerequisites.
|
|
138
|
+
6. After installation, perform the returned `retire` operations through Stripe MCP to
|
|
139
|
+
archive replaced catalog objects, then verify fresh reads again. Test a sandbox
|
|
140
|
+
purchase and its signed event, order grant, booking capacity or recurring invoice;
|
|
141
|
+
a browser return is never proof. Stripe CLI `trigger` can send a test event, and
|
|
142
|
+
`events resend <event> --webhook-endpoint <endpoint>` retries a delivery. Check the
|
|
143
|
+
endpoint's delivery result and the app's verified records. Do not type a card into
|
|
144
|
+
a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
|
|
145
|
+
|
|
146
|
+
Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
|
|
147
|
+
|
|
148
|
+
The owner never handles an API key or signing secret. No Homie backend handles payments.
|
|
149
|
+
The Worker needs only public link configuration and the signing secret; buyers pay
|
|
150
|
+
Stripe directly even when the AI and the owner's computer are offline. The old
|
|
151
|
+
`stripe_login` / `shop connect` flow remains an alternative that syncs everything
|
|
152
|
+
through Stripe CLI browser approval. `--renew` renews that approval; `--live` requires
|
|
153
|
+
the owner's go-live request. Never read CLI config or print login-completion output.
|
|
154
|
+
|
|
155
|
+
For local checkout, pass `url: "http://localhost:8787"` (or the private Wi-Fi address)
|
|
156
|
+
and `mode: "test"` to the same plan/verify flow; the official plugin makes the links.
|
|
157
|
+
Run `homie-studio shop listen --profile <sandbox-profile>` in a background terminal.
|
|
158
|
+
Stripe CLI is required for forwarding; one browser approval is enough if needed.
|
|
159
|
+
The command captures and saves the listener secret privately. Start/restart
|
|
160
|
+
`homie-studio dev --lan` once it reports ready and keep the listener running.
|
|
161
|
+
Never copy the listener's raw output into the conversation.
|
|
162
|
+
|
|
163
|
+
For a faster local iteration, `homie-studio build --quick` skips generated rules play
|
|
164
|
+
and the snapshot smoke-run, while retaining compilation, guards and type checks.
|
|
165
|
+
Run a full build before release; quick output is not evidence that play checks passed.
|
|
124
166
|
|
|
125
167
|
At purchase time, the Worker saves an opaque order reference and sends it as the link's
|
|
126
168
|
`client_reference_id`. The buyer confirms quantity or tip amount on Stripe. A signed snapshot
|
|
@@ -142,17 +184,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
|
|
|
142
184
|
| Managed Payments | Payment Links support managed_payments.enabled; connect uses it when the studio selects Stripe as seller, subject to Stripe eligibility and terms. |
|
|
143
185
|
| Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
|
|
144
186
|
|
|
145
|
-
|
|
146
|
-
The explicit `shop connect --manual` path remains available, including `--live`, but requires the
|
|
147
|
-
owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
|
|
148
|
-
Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
|
|
149
|
-
When switching to spending restrictions, the AI must deactivate previously issued Payment Links through
|
|
150
|
-
the approved CLI and resolve in-flight checkouts before relying on those restrictions; switching credentials
|
|
151
|
-
does not cancel old Stripe sessions. The runtime OAuth finding and sources are in `docs/stripe-connect-research.md` in the source checkout.
|
|
152
|
-
|
|
153
|
-
`shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Stripe's optional MCP
|
|
154
|
-
is useful for owner-requested refunds and analytics; honor its approval links. Do not create a
|
|
155
|
-
secret-returning webhook through a conversation tool: the private connect process owns that call.
|
|
187
|
+
`shop`, `shop orders`, and `/_studio/office/shop` show readiness and sales. Use Stripe's official MCP for owner-requested refunds and analytics; honor its approval links.
|
|
156
188
|
|
|
157
189
|
## Payment guarantees and schema
|
|
158
190
|
|
|
@@ -220,7 +252,7 @@ For referrals, use Stripe Connect to split payments or a Stripe-based affiliate
|
|
|
220
252
|
|
|
221
253
|
## Keyless menus, paid seats and monthly giving (0.51.0)
|
|
222
254
|
|
|
223
|
-
Use
|
|
255
|
+
Use the official MCP plan/verify flow by default. All objects belong to the owner's Stripe account and
|
|
224
256
|
are created through its approved CLI session. Homie has no platform key, OAuth
|
|
225
257
|
backend, payment intermediary or cut. A desktop is needed to sync, not to take money.
|
|
226
258
|
|
|
@@ -251,7 +283,7 @@ data goes in that reference. A return URL is never proof of payment.
|
|
|
251
283
|
collection with integer `capacity` and a server-managed `bookings` collection with
|
|
252
284
|
`"booking":{"slots":"slots","item":"full-tune"}`. Publish slot records, then have
|
|
253
285
|
the owner's AI write the complete manifest to `.studio/booking-slots.json` and run
|
|
254
|
-
`
|
|
286
|
+
`stripe_shop_plan` with the same `slots` array (the CLI alternative is `stripe_bookings_sync`):
|
|
255
287
|
|
|
256
288
|
```json
|
|
257
289
|
[{"app":"tune","collection":"bookings","slots":"slots","slot":"sat-0900","item":"full-tune","capacity":1}]
|
|
@@ -303,7 +335,7 @@ Signed `subscription.started`, `.renewed`, `.failed`, `.cancelled` events drive
|
|
|
303
335
|
membership and giving. Membership access ends at the paid invoice line's period end;
|
|
304
336
|
failed invoices cannot extend it and cancellation ends access. Unknown Prices grant
|
|
305
337
|
nothing. Keyless portal configuration supports cancellation, payment-method updates
|
|
306
|
-
and invoice history.
|
|
338
|
+
and invoice history. The official MCP plan enables `login_page.enabled` through Stripe’s API
|
|
307
339
|
and stores Stripe's no-code login URL; **no Dashboard toggle is required**. Givers
|
|
308
340
|
verify their email with Stripe to manage/cancel. The buyer-owned `/api/shop/portal`
|
|
309
341
|
returns this URL. Plan changes require deliberate portal configuration and fulfilment
|
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.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.5","sha256":"1e11f3b33959ff475ed31b5e5e0b5b4e7a155be104d47db80901422409830b23"});
|
|
@@ -1,3 +1,6 @@
|
|
|
1
|
+
import {McpEventStreams} from './mcp-event-streams.mjs';
|
|
2
|
+
import {listEvents} from './mcp-events.mjs';
|
|
3
|
+
import {notification,sseFrame} from './mcp-events-wire.mjs';
|
|
1
4
|
import {allowed,currentCaller} from './mcp-tools.mjs';
|
|
2
5
|
import {settingsOf,launchOf} from './office.mjs';
|
|
3
6
|
|
|
@@ -44,9 +47,10 @@ export function eventHub(env,connection) {return env.LOBBY.get(env.LOBBY.idFromN
|
|
|
44
47
|
export async function pushEvents(env,cat,origin) {
|
|
45
48
|
if(!env.LOBBY||!Object.keys(eventFeeds(env)).length)return;
|
|
46
49
|
// Page subscriptions, without a studio-wide subscription or delivery cap.
|
|
50
|
+
await env.DB.prepare("DELETE FROM mcp_kv WHERE key LIKE 'mcp-event-stream:%' AND expires<?1").bind(Date.now()).run();
|
|
47
51
|
let after='';const seen=new Set();
|
|
48
52
|
for(;;){
|
|
49
|
-
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'event-sub:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
53
|
+
const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE (key LIKE 'event-sub:%' OR key LIKE 'mcp-event-stream:%' OR key LIKE 'tools-stream:%') AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
50
54
|
for(const row of rows){const {connection}=JSON.parse(row.value);if(seen.has(connection))continue;seen.add(connection);
|
|
51
55
|
const response=await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'wake',connection,cat,origin})});
|
|
52
56
|
if(!response.ok)throw new Error('Event stream wake failed');
|
|
@@ -58,11 +62,13 @@ export async function pushEvents(env,cat,origin) {
|
|
|
58
62
|
/** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
|
|
59
63
|
const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
|
|
60
64
|
export class EventStreams {
|
|
61
|
-
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
|
|
65
|
+
constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();this.extensions=new McpEventStreams(env,ctx);}
|
|
62
66
|
close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream){this.stream=null;this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind('tools-stream:'+stream.connection).run().catch(()=>{}));}}
|
|
63
67
|
background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
|
|
64
68
|
async fetch(request) {
|
|
65
69
|
const input=await request.json();
|
|
70
|
+
if(input.op==='extension-stream')return this.extensions.open(input);
|
|
71
|
+
this.extensions.wake(input);
|
|
66
72
|
if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
|
|
67
73
|
if(input.op==='stream') {
|
|
68
74
|
// A reconnect replaces the previous GET, including a half-open socket.
|
|
@@ -78,6 +84,7 @@ export class EventStreams {
|
|
|
78
84
|
const caller=await streamCaller(this.env,connection).catch(()=>null);
|
|
79
85
|
if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
|
|
80
86
|
stream.toolSignature=await this.toolSignature(input.tools??[],caller);
|
|
87
|
+
stream.eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
81
88
|
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind('tools-stream:'+connection,JSON.stringify({connection})).run();
|
|
82
89
|
// Replay and later wakes share one ordered pump. Backpressure must not
|
|
83
90
|
// hold up the writer's request or a function handler.
|
|
@@ -93,6 +100,8 @@ export class EventStreams {
|
|
|
93
100
|
let caller;
|
|
94
101
|
try{caller=await streamCaller(this.env,input.connection);}
|
|
95
102
|
catch{this.close(stream);return;}
|
|
103
|
+
const eventSignature=JSON.stringify(await listEvents(this.env,input.cat,caller));
|
|
104
|
+
if(stream.eventSignature!==eventSignature){stream.eventSignature=eventSignature;stream.controller.enqueue(sseFrame(notification('list_changed',{})));}
|
|
96
105
|
if(input.tools){
|
|
97
106
|
const signature=await this.toolSignature(input.tools,caller);
|
|
98
107
|
if(stream.toolSignature!==signature){stream.toolSignature=signature;stream.controller.enqueue(new TextEncoder().encode('event: message\ndata: '+JSON.stringify({jsonrpc:'2.0',method:'notifications/tools/list_changed'})+'\n\n'));}
|
|
@@ -130,7 +139,7 @@ export class EventStreams {
|
|
|
130
139
|
export async function pushToolList(env,cat,tools,origin) {
|
|
131
140
|
if(!env.LOBBY)return;
|
|
132
141
|
const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
|
|
133
|
-
const signature=JSON.stringify(publicShape);
|
|
142
|
+
const signature=JSON.stringify([publicShape,eventFeeds(env)]);
|
|
134
143
|
const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
|
|
135
144
|
if(previous?.value===signature)return;
|
|
136
145
|
await env.DB.prepare("INSERT INTO mcp_kv(key,value) VALUES('tools-catalogue',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(signature).run();
|
|
@@ -10,7 +10,7 @@ export async function readableWebhook(env,cat,feed) {
|
|
|
10
10
|
if((feed.audience??'owner')==='owner')return true;
|
|
11
11
|
return feed.audience?.app===app.id && app.roles?.[feed.audience.role]?.can?.includes('read:'+feed.collection)===true && launchOf(app,await settingsOf(env,{fresh:true}),env)==='public';
|
|
12
12
|
}
|
|
13
|
-
class WebhookFailure extends Error {
|
|
13
|
+
export class WebhookFailure extends Error {
|
|
14
14
|
constructor(message){super(message);this.name='WebhookFailure';}
|
|
15
15
|
}
|
|
16
16
|
export function webhookFailure(error){return error instanceof WebhookFailure?error.message:'Webhook delivery failed';}
|
|
@@ -21,23 +21,28 @@ export async function postWebhook(env,name,event,{fetchFn=globalThis.fetch.bind(
|
|
|
21
21
|
validateWebhook(name,hook);
|
|
22
22
|
const secret=env[hook.secret];
|
|
23
23
|
if(typeof secret!=='string'||!/^whsec_[A-Za-z0-9+/]{43}=$/.test(secret))throw new WebhookFailure('Webhook signing secret unavailable');
|
|
24
|
-
const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
|
|
25
|
-
// Encode the event ID to avoid ambiguous dot-delimited signatures and invalid headers.
|
|
26
24
|
const id='evt_'+btoa(String.fromCharCode(...new TextEncoder().encode(event.id))).replaceAll('+','-').replaceAll('/','_').replaceAll('=','');
|
|
25
|
+
return signedWebhook(hook.url,{id,body:{id:event.id,type:event.type,at:event.at,data:event.data},secrets:[secret]},{fetchFn,now,timeoutMs});
|
|
26
|
+
}
|
|
27
|
+
/** Shared transport for configured studio hooks and MCP Events subscriptions. */
|
|
28
|
+
export async function signedWebhook(url,{id,body,secrets,headers={}},{fetchFn=globalThis.fetch.bind(globalThis),now=Date.now,timeoutMs=10000}={}) {
|
|
27
29
|
const timestamp=String(Math.floor(now()/1000));
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
30
|
+
const encoded=JSON.stringify(body),signatures=[];
|
|
31
|
+
for(const secret of secrets){
|
|
32
|
+
const key=await crypto.subtle.importKey('raw',Uint8Array.from(atob(secret.slice(6)),c=>c.charCodeAt(0)),{name:'HMAC',hash:'SHA-256'},false,['sign']);
|
|
33
|
+
signatures.push('v1,'+btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${encoded}`))))));
|
|
34
|
+
}
|
|
35
|
+
const controller=new AbortController(),timer=setTimeout(()=>controller.abort(),timeoutMs);
|
|
31
36
|
try {
|
|
32
|
-
const response=await fetchFn(
|
|
33
|
-
// Never read or log receiver content: it may echo credentials or private records.
|
|
37
|
+
const response=await fetchFn(url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':signatures.join(' '),...headers},body:encoded});
|
|
34
38
|
await response.body?.cancel();
|
|
35
|
-
if(!response.ok)
|
|
39
|
+
if(!response.ok){const error=new WebhookFailure('Webhook HTTP '+response.status);error.status=response.status;throw error;}
|
|
36
40
|
}catch(error){
|
|
37
41
|
if(error instanceof WebhookFailure)throw error;
|
|
38
42
|
throw new WebhookFailure(controller.signal.aborted?'Webhook timed out':'Webhook network failure');
|
|
39
43
|
}finally{clearTimeout(timer);}
|
|
40
44
|
}
|
|
45
|
+
|
|
41
46
|
export function webhookSubscriptions(env,cat,options={}) {
|
|
42
47
|
return Object.entries(eventFeeds(env)).filter(([,feed])=>feed.webhook).map(([name,feed])=>({
|
|
43
48
|
name:webhookName(name,feed.webhook),event:feed.event,webhook:true,
|
package/worker/functions.mjs
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import {eventHookSubscriptions} from './mcp-events.mjs';
|
|
1
2
|
import {webhookSubscriptions,webhookFailure,webhookBackoff} from './event-webhooks.mjs';
|
|
2
3
|
import {parseSchedule,scheduleStatus} from './function-schedule.mjs';
|
|
3
4
|
import {redactText} from '../lib/redact.mjs';
|
|
@@ -15,7 +16,7 @@ export async function syncFunctions(env, definitions = null, now = Date.now()) {
|
|
|
15
16
|
// Deploy owns the production registry; an old in-flight Worker must not overwrite it.
|
|
16
17
|
if(await env.DB.prepare("SELECT 1 FROM meta WHERE key='function-registry-deployed'").first())return definitions;
|
|
17
18
|
const statements=definitions.map(fn=>env.DB.prepare(`INSERT INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,(SELECT COALESCE(MAX(seq),0) FROM studio_events),?3) ON CONFLICT(name) DO UPDATE SET type=excluded.type,cursor=excluded.cursor,started=excluded.started WHERE type!=excluded.type`).bind(fn.name,fn.event,now));
|
|
18
|
-
statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
|
|
19
|
+
statements.push(env.DB.prepare(`DELETE FROM function_subscriptions WHERE name NOT LIKE '@mcp-webhook/%' AND name NOT IN (SELECT value FROM json_each(?1))`).bind(JSON.stringify(definitions.map(f=>f.name))));
|
|
19
20
|
await env.DB.batch(statements);
|
|
20
21
|
return definitions;
|
|
21
22
|
}
|
|
@@ -33,7 +34,7 @@ export async function pruneEvents(env) {
|
|
|
33
34
|
const cursor=Number((await env.DB.prepare("SELECT value FROM meta WHERE key='function-prune-cursor'").first())?.value??0);
|
|
34
35
|
const rows=(await env.DB.prepare('SELECT seq,id FROM studio_events WHERE seq>?1 ORDER BY seq LIMIT 100').bind(cursor).all()).results;
|
|
35
36
|
const statements=rows.flatMap(({id})=>[
|
|
36
|
-
env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
|
|
37
|
+
env.DB.prepare(`DELETE FROM function_deliveries WHERE event=?1 AND ((state='complete' AND function NOT LIKE '@webhook/%' AND function NOT LIKE '@mcp-webhook/%') OR NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.name=function_deliveries.function))`).bind(id),
|
|
37
38
|
env.DB.prepare(`DELETE FROM studio_events WHERE id=?1 AND NOT EXISTS (SELECT 1 FROM function_subscriptions s WHERE s.type=studio_events.type AND s.cursor<studio_events.seq) AND NOT EXISTS (SELECT 1 FROM function_deliveries d WHERE d.event=studio_events.id)`).bind(id),
|
|
38
39
|
]);
|
|
39
40
|
statements.push(env.DB.prepare("INSERT INTO meta(key,value) VALUES('function-prune-cursor',?1) ON CONFLICT(key) DO UPDATE SET value=excluded.value").bind(String(rows.at(-1)?.seq??0)));
|
|
@@ -41,10 +42,11 @@ export async function pruneEvents(env) {
|
|
|
41
42
|
}
|
|
42
43
|
/** A lease is for retry after a crashed Worker, not a deadline imposed on studio code. */
|
|
43
44
|
export async function runFunctions(env, cat, origin, {definitions, now = Date.now, leaseMs = 60000, webhookOptions = {}} = {}) {
|
|
45
|
+
if(!env.DB)return;
|
|
44
46
|
definitions = await syncFunctions(env,definitions,now());
|
|
45
|
-
if(!definitions.length)return;
|
|
47
|
+
if(!definitions.length&&!env.STUDIO_EVENT_FEEDS)return;
|
|
46
48
|
if(typeof cat==='function')cat=await cat();
|
|
47
|
-
definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions));
|
|
49
|
+
definitions=definitions.filter(fn=>!fn.webhook).concat(webhookSubscriptions(env,cat,webhookOptions),env.STUDIO_EVENT_FEEDS?await eventHookSubscriptions(env,cat,{...webhookOptions,now}):[]);
|
|
48
50
|
// An AI notification must not wait behind a slow function handler.
|
|
49
51
|
const notify=()=>pushEvents(env,cat,origin).catch(error=>console.error('Event notification wake failed',error));
|
|
50
52
|
const notifications=notify();
|
|
@@ -72,18 +74,21 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
|
|
|
72
74
|
env.DB.prepare("INSERT INTO function_runs(id,function,event,trigger,started,outcome,attempt) SELECT ?1,?2,?3,?4,?5,'running',attempts FROM function_deliveries WHERE event=?3 AND function=?2 AND lease=?1").bind(lease,fn.name,row.event,row.event.startsWith(`schedule:${fn.name}:`)?`schedule: ${JSON.parse(row.data).cron}`:row.type,started),
|
|
73
75
|
]);
|
|
74
76
|
if (!claimed.meta.changes) continue;
|
|
75
|
-
let failure=null;
|
|
77
|
+
let failure=null,permanent=false;
|
|
76
78
|
try {
|
|
77
79
|
const stored=JSON.parse(row.data);
|
|
78
80
|
const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
|
|
79
81
|
const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:isolated?{...stored,app:isolated[1]}:stored,attempt:row.attempts+1});
|
|
80
82
|
const testScope=isolated?isolated[2]:null;
|
|
81
83
|
await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
|
|
82
|
-
} catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
84
|
+
} catch(error) {permanent=error.permanent===true;failure=fn.webhook?webhookFailure(error):functionError(error,env);}
|
|
83
85
|
const finished=now();
|
|
86
|
+
const firstAttempt=fn.mcpEvent?(await env.DB.prepare('SELECT MIN(started) at FROM function_runs WHERE event=?1 AND function=?2').bind(row.event,fn.name).first()).at:finished;
|
|
87
|
+
const abandoned=fn.mcpEvent&&failure!==null&&(permanent||row.attempts+1>=5||finished-firstAttempt>=900000);
|
|
84
88
|
await env.DB.batch([
|
|
85
89
|
failure===null
|
|
86
90
|
? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=NULL WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished)
|
|
91
|
+
: abandoned ? env.DB.prepare(`UPDATE function_deliveries SET state='complete',completed=?4,error=?5 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished,failure)
|
|
87
92
|
: env.DB.prepare(`UPDATE function_deliveries SET state='failed',error=?5,due=?4 WHERE event=?1 AND function=?2 AND lease=?3`).bind(row.event,fn.name,lease,finished+(fn.webhook?webhookBackoff(row.attempts+1):leaseMs),fn.webhook?failure:'Function failed; inspect its code and retry'),
|
|
88
93
|
env.DB.prepare("UPDATE function_runs SET outcome=?2,finished=?3,error=?4 WHERE id=?1").bind(lease,failure===null?'complete':'failed',finished,failure),
|
|
89
94
|
]);
|
package/worker/index.mjs
CHANGED
|
@@ -189,7 +189,11 @@ async function availableShellShop(env, cat, game, options) {
|
|
|
189
189
|
// The shell can show a disabled catalog before payment setup is complete.
|
|
190
190
|
// Checkout readiness is enforced by the shop API, separately from visibility.
|
|
191
191
|
const preview = (cat.games ?? []).find(g => g.id === game)?.screen?.shopPreview === true;
|
|
192
|
-
|
|
192
|
+
const shop = shopOf(cat);
|
|
193
|
+
// Free services must retain their bridge in a mixed free/paid catalogue.
|
|
194
|
+
// Paid readiness, policy and atomic booking capacity remain API decisions.
|
|
195
|
+
const free = shop?.open && !shop.broken && shop.items.some(i => i.price === 0 && !i.interval && (!i.game || i.game === game));
|
|
196
|
+
return (preview && !shop?.broken || free || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
|
|
193
197
|
}
|
|
194
198
|
|
|
195
199
|
async function postsOf(env, origin) {
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {eventSource,eventCaller,checkEvent,eventStart,pollEvents} from './mcp-events.mjs';
|
|
2
|
+
import {EVENT_ERRORS,terminalEventError,EventsError,eventError,notification,sseFrame,streamResult,cursorOf} from './mcp-events-wire.mjs';
|
|
3
|
+
|
|
4
|
+
/** Connection-scoped push subscriptions hosted by the existing per-connection Lobby. */
|
|
5
|
+
export class McpEventStreams {
|
|
6
|
+
constructor(env,ctx){this.env=env;this.ctx=ctx;this.streams=new Set();}
|
|
7
|
+
close(stream){if(stream.closed)return;stream.closed=true;clearInterval(stream.timer);stream.resume?.();try{stream.controller.close();}catch{}this.streams.delete(stream);if(stream.registryKey)this.ctx.waitUntil(this.env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(stream.registryKey).run().catch(()=>{}));}
|
|
8
|
+
async write(stream,value,authorize){
|
|
9
|
+
if(stream.closed)return false;
|
|
10
|
+
if(stream.controller.desiredSize<=0)await new Promise(resolve=>{stream.resume=resolve;});
|
|
11
|
+
if(stream.closed)return false;
|
|
12
|
+
if(authorize)await authorize();
|
|
13
|
+
try{stream.controller.enqueue(sseFrame(value));return true;}catch{this.close(stream);return false;}
|
|
14
|
+
}
|
|
15
|
+
wake(input){for(const stream of this.streams){if(input.cat)stream.cat=input.cat;this.pump(stream);}}
|
|
16
|
+
pump(stream){if(stream.busy||stream.closed)return;stream.busy=true;this.ctx.waitUntil(this.deliver(stream).catch(()=>this.close(stream)).finally(()=>{stream.busy=false;}));}
|
|
17
|
+
async open(input){
|
|
18
|
+
try{
|
|
19
|
+
const caller=await eventCaller(this.env,input.connection);const feed=await checkEvent(this.env,input.cat,caller,input.params);
|
|
20
|
+
const start=await eventStart(this.env,input.params);
|
|
21
|
+
const registryKey='mcp-event-stream:'+input.connection+':'+crypto.randomUUID();
|
|
22
|
+
await this.env.DB.prepare('INSERT INTO mcp_kv(key,value,expires) VALUES(?1,?2,?3)').bind(registryKey,JSON.stringify({connection:input.connection}),Date.now()+60000).run();
|
|
23
|
+
const stream={...input,registryKey,source:eventSource(feed),cursor:cursorOf(input.params.name,start.after),closed:false,busy:false};
|
|
24
|
+
const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(sseFrame(notification('active',{cursor:stream.cursor,truncated:start.truncated},stream.id)));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
|
|
25
|
+
this.streams.add(stream);
|
|
26
|
+
// Heartbeats also recheck authorization, catch missed wakes and renew discovery.
|
|
27
|
+
stream.timer=setInterval(()=>this.pump(stream),15000);
|
|
28
|
+
this.pump(stream);
|
|
29
|
+
return new Response(body,{headers:{'content-type':'text/event-stream','cache-control':'no-store'}});
|
|
30
|
+
}catch(error){return Response.json({jsonrpc:'2.0',id:input.id,error:eventError(error)});}
|
|
31
|
+
}
|
|
32
|
+
async deliver(stream){
|
|
33
|
+
try{
|
|
34
|
+
let more=true;
|
|
35
|
+
while(more&&!stream.closed){
|
|
36
|
+
const caller=await eventCaller(this.env,stream.connection);
|
|
37
|
+
const feed=await checkEvent(this.env,stream.cat,caller,stream.params);
|
|
38
|
+
if(stream.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});
|
|
39
|
+
const start=await eventStart(this.env,{name:stream.params.name,cursor:stream.cursor});
|
|
40
|
+
if(start.truncated){stream.cursor=cursorOf(stream.params.name,start.after);await this.write(stream,notification('active',{cursor:stream.cursor,truncated:true},stream.id));}
|
|
41
|
+
const page=await pollEvents(this.env,stream.cat,caller,{...stream.params,maxAgeMs:undefined,cursor:stream.cursor});
|
|
42
|
+
for(const event of page.events){
|
|
43
|
+
await checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params);
|
|
44
|
+
if(!await this.write(stream,notification('event',event,stream.id),async()=>checkEvent(this.env,stream.cat,await eventCaller(this.env,stream.connection),stream.params)))return;
|
|
45
|
+
stream.cursor=event.cursor;
|
|
46
|
+
}
|
|
47
|
+
stream.cursor=page.cursor;more=page.hasMore;
|
|
48
|
+
}
|
|
49
|
+
await this.env.DB.prepare('UPDATE mcp_kv SET expires=?2 WHERE key=?1').bind(stream.registryKey,Date.now()+60000).run();
|
|
50
|
+
await this.write(stream,notification('heartbeat',{cursor:stream.cursor},stream.id));
|
|
51
|
+
}catch(error){
|
|
52
|
+
const details=eventError(error);
|
|
53
|
+
if(terminalEventError(details.code)){
|
|
54
|
+
await this.write(stream,notification('terminated',{error:details},stream.id));await this.write(stream,streamResult(stream.id));this.close(stream);
|
|
55
|
+
}else await this.write(stream,notification('error',{error:details},stream.id));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Experimental MCP Events design sketch (2026-10-14 snapshot).
|
|
2
|
+
* Keep draft method names, envelopes, schemas and provisional codes here.
|
|
3
|
+
* https://github.com/modelcontextprotocol/experimental-ext-triggers-events/blob/13e0a59749cde947374aaa8e3a65f0b424468d5a/docs/design-sketch-proposal.md
|
|
4
|
+
*/
|
|
5
|
+
export const eventsCapability={events:{listChanged:true}};
|
|
6
|
+
export const EVENTS=Object.freeze({list:'events/list',poll:'events/poll',stream:'events/stream',subscribe:'events/subscribe',unsubscribe:'events/unsubscribe'});
|
|
7
|
+
export const eventMethods=new Set(Object.values(EVENTS));
|
|
8
|
+
export const EVENT_ERRORS=Object.freeze({invalid:-32602,notFound:-32023,forbidden:-32024,exhausted:-32025,unsupported:-32026,callback:-32027});
|
|
9
|
+
const messages={[-32602]:'InvalidParams',[-32023]:'NotFound',[-32024]:'Forbidden',[-32025]:'ResourceExhausted',[-32026]:'Unsupported',[-32027]:'CallbackEndpointError'};
|
|
10
|
+
export class EventsError extends Error {
|
|
11
|
+
constructor(code,data){super(messages[code]);this.code=code;this.data=data;}
|
|
12
|
+
}
|
|
13
|
+
export const invalid=()=>{throw new EventsError(-32602);};
|
|
14
|
+
export function eventError(error){return error instanceof EventsError?{code:error.code,message:error.message,...(error.data?{data:error.data}:{})}:{code:-32603,message:'Internal error'};}
|
|
15
|
+
export function eventDescriptor(name,feed){return {name,description:feed.description??`Studio ${feed.event} events; untrusted data`,delivery:['poll','push','webhook'],inputSchema:{type:'object',properties:{},additionalProperties:false},payloadSchema:{type:'object'}};}
|
|
16
|
+
export function occurrence(name,event,cursor){return {eventId:event.id,name,timestamp:new Date(event.at).toISOString(),data:event.data,...(cursor===undefined?{}:{cursor})};}
|
|
17
|
+
export const cursorOf=(name,seq)=>'v1:'+name+':'+seq;
|
|
18
|
+
export function cursorPosition(name,cursor){if(cursor==null)return null;if(typeof cursor!=='string'||!cursor.startsWith('v1:'+name+':'))invalid();const value=cursor.slice(name.length+4);if(!/^(0|[1-9][0-9]*)$/.test(value)||!Number.isSafeInteger(Number(value)))invalid();return Number(value);}
|
|
19
|
+
export function validateEventParams(params,{list=false}={}){
|
|
20
|
+
if(!params||typeof params!=='object'||Array.isArray(params))invalid();
|
|
21
|
+
if(list){if(params.cursor!=null)invalid();return;}
|
|
22
|
+
if(typeof params.name!=='string'||!params.name)invalid();
|
|
23
|
+
if(params.arguments!==undefined&&(!params.arguments||typeof params.arguments!=='object'||Array.isArray(params.arguments)||Object.keys(params.arguments).length))invalid();
|
|
24
|
+
cursorPosition(params.name,params.cursor);
|
|
25
|
+
for(const key of ['maxAgeMs','maxEvents','ttlMs'])if(params[key]!==undefined&&!(key==='ttlMs'&¶ms[key]===null)&&(!Number.isSafeInteger(params[key])||params[key]<(key==='maxEvents'?1:0)))invalid();
|
|
26
|
+
}
|
|
27
|
+
export function callbackUrl(value){let url;try{url=new URL(value);}catch{invalid();}if(url.protocol!=='https:'||url.username||url.password||url.hash)invalid();return url.href;}
|
|
28
|
+
export function signingSecret(value){if(typeof value!=='string'||!/^whsec_(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value))invalid();let raw;try{raw=atob(value.slice(6));}catch{invalid();}if(raw.length<24||raw.length>64||btoa(raw)!==value.slice(6))invalid();return value;}
|
|
29
|
+
export const notification=(type,params,id)=>({jsonrpc:'2.0',method:'notifications/events/'+type,params:{...params,...(id===undefined?{}:{_meta:{'io.modelcontextprotocol/subscriptionId':id}})}});
|
|
30
|
+
export const sseFrame=value=>new TextEncoder().encode('event: message\ndata: '+JSON.stringify(value)+'\n\n');
|
|
31
|
+
export const streamResult=id=>({jsonrpc:'2.0',id,result:{_meta:{}}});
|
|
32
|
+
export const control=(type,fields)=>({type,...fields});
|
|
33
|
+
export const subscriptionResult=(sub,cursor,truncated,deliveryStatus)=>({id:sub.id,refreshBefore:new Date(sub.expires).toISOString(),cursor,truncated,...(deliveryStatus?{deliveryStatus}:{})});
|
|
34
|
+
export const subscriptionHeaders=id=>({'X-MCP-Subscription-Id':id});
|
|
35
|
+
/** Internal pages keep stable names; draft field changes stay at the transport boundary. */
|
|
36
|
+
export function eventsResult(method,value){
|
|
37
|
+
if(method===EVENTS.list)return {events:value.events};
|
|
38
|
+
if(method===EVENTS.poll)return {events:value.events.map(({cursor,...event})=>event),cursor:value.cursor,truncated:value.truncated,hasMore:value.hasMore,nextPollMs:value.nextPollMs};
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
41
|
+
export const terminalEventError=code=>[EVENT_ERRORS.notFound,EVENT_ERRORS.forbidden,EVENT_ERRORS.unsupported].includes(code);
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import {rateLimit,callsPerMinute} from './mcp-store.mjs';
|
|
2
|
+
import {eventFeeds,readableFeed,matchesFeed,eventHub} from './event-resources.mjs';
|
|
3
|
+
import {currentCaller} from './mcp-tools.mjs';
|
|
4
|
+
import {signedWebhook,WebhookFailure} from './event-webhooks.mjs';
|
|
5
|
+
import {EVENTS,EVENT_ERRORS,eventsResult,EventsError,invalid,eventError,eventDescriptor,occurrence,cursorOf,cursorPosition,validateEventParams,callbackUrl,signingSecret,subscriptionResult,subscriptionHeaders,control} from './mcp-events-wire.mjs';
|
|
6
|
+
|
|
7
|
+
const prefix='mcp-event-hook:';
|
|
8
|
+
export const eventSource=feed=>JSON.stringify([feed.event,feed.app??null,feed.collection??null]);
|
|
9
|
+
export const hookFunction=id=>'@mcp-webhook/'+id;
|
|
10
|
+
const head=async env=>(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events').first()).seq;
|
|
11
|
+
export async function eventCaller(env,connection){
|
|
12
|
+
if(connection==='local-test-owner'&&env.HOMIE_LOCAL_TEST_SECRET)return {id:'local-test-owner',owner:true,connection};
|
|
13
|
+
try{return await currentCaller(env,{connection});}catch{throw new EventsError(EVENT_ERRORS.forbidden);}
|
|
14
|
+
}
|
|
15
|
+
export async function checkEvent(env,cat,caller,params){
|
|
16
|
+
validateEventParams(params);
|
|
17
|
+
const feeds=eventFeeds(env),feed=Object.hasOwn(feeds,params.name)?feeds[params.name]:null;
|
|
18
|
+
if(!feed)throw new EventsError(EVENT_ERRORS.notFound,{kind:'event'});
|
|
19
|
+
if(!await readableFeed(env,cat,caller,params.name))throw new EventsError(EVENT_ERRORS.forbidden);
|
|
20
|
+
return feed;
|
|
21
|
+
}
|
|
22
|
+
export async function listEvents(env,cat,caller,params={}){
|
|
23
|
+
validateEventParams(params,{list:true});const events=[];
|
|
24
|
+
for(const [name,feed] of Object.entries(eventFeeds(env)))if(await readableFeed(env,cat,caller,name))events.push(eventDescriptor(name,feed));
|
|
25
|
+
return {events};
|
|
26
|
+
}
|
|
27
|
+
export async function eventStart(env,params,now=Date.now()){
|
|
28
|
+
const through=await head(env);let after=cursorPosition(params.name,params.cursor),truncated=false;
|
|
29
|
+
if(after===null)return {after:through,through,truncated};
|
|
30
|
+
if(after>through){after=through;truncated=true;}
|
|
31
|
+
const first=(await env.DB.prepare('SELECT MIN(seq) seq FROM studio_events').first()).seq;
|
|
32
|
+
if(first!==null&&after<first-1){after=first-1;truncated=true;}
|
|
33
|
+
if(params.maxAgeMs!==undefined){const floor=(await env.DB.prepare('SELECT COALESCE(MAX(seq),0) seq FROM studio_events WHERE at<?1 AND seq<=?2').bind(now-params.maxAgeMs,through).first()).seq;if(floor>after){after=floor;truncated=true;}}
|
|
34
|
+
return {after,through,truncated};
|
|
35
|
+
}
|
|
36
|
+
export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
37
|
+
const feed=await checkEvent(env,cat,caller,params),start=await eventStart(env,params,now);
|
|
38
|
+
const max=Math.min(params.maxEvents??100,100);
|
|
39
|
+
// Filter before limiting: unrelated collections must not create a false hasMore
|
|
40
|
+
// or force the client through pages containing no matching occurrences.
|
|
41
|
+
const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
|
|
42
|
+
WHERE type=?1 AND seq>?2 AND seq<=?3
|
|
43
|
+
AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
|
|
44
|
+
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?feed.app:null,feed.collection??null,max+1).all()).results;
|
|
45
|
+
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
|
+
const events=page.map(row=>occurrence(params.name,{...row,data:JSON.parse(row.data)},cursorOf(params.name,row.seq)));
|
|
47
|
+
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
|
+
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
|
|
49
|
+
}
|
|
50
|
+
async function subId(caller,params,url){const bytes=await crypto.subtle.digest('SHA-256',new TextEncoder().encode(JSON.stringify([caller.id,url,params.name,{}])));return 'sub_'+Array.from(new Uint8Array(bytes),b=>b.toString(16).padStart(2,'0')).join('');}
|
|
51
|
+
async function stored(env,id){const row=await env.DB.prepare('SELECT value FROM mcp_kv WHERE key=?1').bind(prefix+id).first();return row?JSON.parse(row.value):null;}
|
|
52
|
+
export async function removeEventHook(env,id){await env.DB.batch([env.DB.prepare('DELETE FROM mcp_kv WHERE key=?1').bind(prefix+id),env.DB.prepare('DELETE FROM function_subscriptions WHERE name=?1').bind(hookFunction(id)),env.DB.prepare('DELETE FROM function_deliveries WHERE function=?1').bind(hookFunction(id))]);}
|
|
53
|
+
export function deliveryCategory(error){if(error.message==='challenge_failed')return 'challenge_failed';if(error.message==='Webhook timed out')return 'timeout';if(error.status>=500)return 'http_5xx';if(error.status)return 'http_4xx';return 'connection_refused';}
|
|
54
|
+
// Worker fetch cannot pin an independently resolved address with original TLS SNI.
|
|
55
|
+
// Restrict callbacks to exact owner-declared URLs, rechecked on every attempt.
|
|
56
|
+
// This also supplies the draft's server-configured endpoint-consent allowlist.
|
|
57
|
+
export function permittedCallback(env,name,url){
|
|
58
|
+
const feed=eventFeeds(env)[name];
|
|
59
|
+
return [...(feed?.receivers??[]),...(feed?.webhook?[feed.webhook.url]:[])].some(value=>callbackUrl(value)===url);
|
|
60
|
+
}
|
|
61
|
+
async function send(env,sub,body,id,options={}){
|
|
62
|
+
if(!permittedCallback(env,sub.name,sub.url))throw new WebhookFailure('Callback no longer allowed');
|
|
63
|
+
const secrets=[sub.secret,...(sub.previous&&sub.rotateUntil>(options.now??Date.now)()?[sub.previous]:[])];
|
|
64
|
+
return signedWebhook(sub.url,{id,body,secrets,headers:subscriptionHeaders(sub.id)},options);
|
|
65
|
+
}
|
|
66
|
+
async function watermark(env,sub){
|
|
67
|
+
const registry=await env.DB.prepare('SELECT cursor FROM function_subscriptions WHERE name=?1').bind(hookFunction(sub.id)).first();
|
|
68
|
+
const pending=await env.DB.prepare("SELECT MIN(e.seq) seq FROM function_deliveries d JOIN studio_events e ON e.id=d.event WHERE d.function=?1 AND d.state!='complete'").bind(hookFunction(sub.id)).first();
|
|
69
|
+
return cursorOf(sub.name,Math.max(sub.start,Math.min(registry?.cursor??sub.start,pending.seq===null?Number.MAX_SAFE_INTEGER:pending.seq-1)));
|
|
70
|
+
}
|
|
71
|
+
export async function subscribeEvents(env,cat,caller,params,{now=Date.now}={}){
|
|
72
|
+
if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);
|
|
73
|
+
const feed=await checkEvent(env,cat,caller,params);
|
|
74
|
+
if(!params.delivery||typeof params.delivery!=='object')invalid();
|
|
75
|
+
if(params.delivery.mode!==undefined&¶ms.delivery.mode!=='webhook')throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:params.delivery.mode});
|
|
76
|
+
const url=callbackUrl(params.delivery.url),secret=signingSecret(params.delivery.secret);
|
|
77
|
+
if(!permittedCallback(env,params.name,url))throw new EventsError(EVENT_ERRORS.callback,{reason:'challenge_failed'});
|
|
78
|
+
const id=await subId(caller,params,url),previous=await stored(env,id),live=previous&&previous.expires>now();
|
|
79
|
+
if(live&&previous.source!==eventSource(feed)){await removeEventHook(env,id);throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
80
|
+
if(previous&&!live)await removeEventHook(env,id);
|
|
81
|
+
if(!live&&feed.maxSubscriptions!==undefined){const rows=(await env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%'").all()).results;if(rows.filter(row=>{const sub=JSON.parse(row.value);return sub.name===params.name&&sub.expires>now();}).length>=feed.maxSubscriptions)throw new EventsError(EVENT_ERRORS.exhausted,{limit:'subscriptions',max:feed.maxSubscriptions});}
|
|
82
|
+
const start=await eventStart(env,params,now());
|
|
83
|
+
const sub={id,name:params.name,source:eventSource(feed),url,secret,principal:caller.id,connection:caller.connection??'local-test-owner',start:live?previous.start:start.after,expires:now()+Math.min(params.ttlMs??3600000,86400000)};
|
|
84
|
+
if(live){if(previous.secret!==secret){sub.previous=previous.secret;sub.rotateUntil=now()+300000;}else if(previous.rotateUntil>now()){sub.previous=previous.previous;sub.rotateUntil=previous.rotateUntil;}}
|
|
85
|
+
await env.DB.batch([
|
|
86
|
+
env.DB.prepare('INSERT INTO mcp_kv(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value').bind(prefix+id,JSON.stringify(sub)),
|
|
87
|
+
env.DB.prepare('INSERT OR IGNORE INTO function_subscriptions(name,type,cursor,started) VALUES(?1,?2,?3,?4)').bind(hookFunction(id),feed.event,sub.start,now()),
|
|
88
|
+
]);
|
|
89
|
+
const latest=await env.DB.prepare("SELECT error,completed FROM function_deliveries WHERE function=?1 ORDER BY CASE WHEN state!='complete' AND error IS NOT NULL THEN 0 ELSE 1 END, rowid DESC LIMIT 1").bind(hookFunction(id)).first();
|
|
90
|
+
return subscriptionResult(sub,await watermark(env,sub),live?false:start.truncated,{active:true,lastError:latest?.error??null,lastDeliveryAt:latest?.completed?new Date(latest.completed).toISOString():null});
|
|
91
|
+
}
|
|
92
|
+
export async function unsubscribeEvents(env,caller,params){
|
|
93
|
+
if(!caller.id)throw new EventsError(EVENT_ERRORS.forbidden);validateEventParams(params);
|
|
94
|
+
const id=await subId(caller,params,callbackUrl(params.delivery?.url)),sub=await stored(env,id);
|
|
95
|
+
if(!sub||sub.expires<=Date.now())throw new EventsError(EVENT_ERRORS.notFound,{kind:'subscription'});
|
|
96
|
+
await removeEventHook(env,id);return {};
|
|
97
|
+
}
|
|
98
|
+
/** Dynamic hooks become ordinary durable function deliveries: one queue and retry engine. */
|
|
99
|
+
export async function eventHookSubscriptions(env,cat,options={}){
|
|
100
|
+
const now=options.now??Date.now,definitions=[];let after='';
|
|
101
|
+
for(;;){const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'mcp-event-hook:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
|
|
102
|
+
for(const row of rows){const sub=JSON.parse(row.value);if(sub.expires<=now()){await removeEventHook(env,sub.id);continue;}
|
|
103
|
+
let feed;
|
|
104
|
+
try{feed=await checkEvent(env,cat,await eventCaller(env,sub.connection),{name:sub.name});if(!permittedCallback(env,sub.name,sub.url))throw new EventsError(EVENT_ERRORS.forbidden);if(sub.source!==eventSource(feed))throw new EventsError(EVENT_ERRORS.unsupported,{feature:'payloadSchema',reason:'schema_changed'});}
|
|
105
|
+
catch(error){try{await send(env,sub,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,sub.id);continue;}
|
|
106
|
+
definitions.push({name:hookFunction(sub.id),event:feed.event,webhook:true,mcpEvent:true,matches:event=>matchesFeed(feed,event),async handler(event){
|
|
107
|
+
const fresh=await stored(env,sub.id);if(!fresh||fresh.expires<=now())return;
|
|
108
|
+
try{await checkEvent(env,cat,await eventCaller(env,fresh.connection),{name:fresh.name});}
|
|
109
|
+
catch(error){try{await send(env,fresh,control('terminated',{error:eventError(error)}),'msg_terminated_'+crypto.randomUUID(),options);}catch{}await removeEventHook(env,fresh.id);return;}
|
|
110
|
+
try{await send(env,fresh,occurrence(fresh.name,event,await watermark(env,fresh)),event.id,options);}
|
|
111
|
+
catch(error){const failure=new WebhookFailure(deliveryCategory(error));failure.permanent=error.status===410||error.status===413;throw failure;}
|
|
112
|
+
}});
|
|
113
|
+
}
|
|
114
|
+
if(rows.length<100)break;after=rows.at(-1).key;
|
|
115
|
+
}return definitions;
|
|
116
|
+
}
|
|
117
|
+
export async function eventsRequest(request,env,ctx,cat,caller,body){
|
|
118
|
+
const params=body.params??{};
|
|
119
|
+
try{
|
|
120
|
+
if(!await rateLimit(env,caller.id??`public:${request.headers.get('cf-connecting-ip')??'local'}`,callsPerMinute(cat)))throw new EventsError(EVENT_ERRORS.exhausted,{limit:'requests'});
|
|
121
|
+
if(body.jsonrpc!=='2.0'||!['string','number'].includes(typeof body.id))invalid();
|
|
122
|
+
if(body.method===EVENTS.stream){
|
|
123
|
+
await checkEvent(env,cat,caller,params);
|
|
124
|
+
if(!env.LOBBY)throw new EventsError(EVENT_ERRORS.unsupported,{feature:'deliveryMode',value:'push'});
|
|
125
|
+
const connection=caller.connection??'local-test-owner';
|
|
126
|
+
return eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'extension-stream',connection,cat,id:body.id,params})});
|
|
127
|
+
}
|
|
128
|
+
const result=body.method===EVENTS.list?await listEvents(env,cat,caller,params):body.method===EVENTS.poll?await pollEvents(env,cat,caller,params):body.method===EVENTS.subscribe?await subscribeEvents(env,cat,caller,params):await unsubscribeEvents(env,caller,params);
|
|
129
|
+
if(body.method===EVENTS.subscribe){const work=import('./functions.mjs').then(m=>m.runFunctions(env,cat,new URL(request.url).origin));if(ctx?.waitUntil)ctx.waitUntil(work);else await work;}
|
|
130
|
+
return Response.json({jsonrpc:'2.0',id:body.id,result:eventsResult(body.method,result)},{headers:{'cache-control':'no-store'}});
|
|
131
|
+
}catch(error){return Response.json({jsonrpc:'2.0',id:body.id??null,error:eventError(error)},{headers:{'cache-control':'no-store'}});}
|
|
132
|
+
}
|
package/worker/mcp.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import {eventMethods,eventsCapability} from './mcp-events-wire.mjs';
|
|
2
|
+
import {eventsRequest} from './mcp-events.mjs';
|
|
1
3
|
function freshErrorMessage(error,env,caller) {
|
|
2
4
|
let message=String(error?.message??error);
|
|
3
5
|
for(const [key,value] of Object.entries(env))if(typeof value==='string'&&/SECRET|KEY|TOKEN|PASSWORD/.test(key)&&value.length)message=message.replaceAll(value,'[redacted]');
|
|
@@ -50,6 +52,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
50
52
|
}
|
|
51
53
|
if(request.method==='POST') {
|
|
52
54
|
const body=await request.clone().json().catch(()=>null);
|
|
55
|
+
if(eventMethods.has(body?.method))return eventsRequest(request,env,ctx,cat,caller,body);
|
|
53
56
|
if(body?.method==='tools/call') {
|
|
54
57
|
const tool=tools.find(t=>t.name===body.params?.name);
|
|
55
58
|
const schema=tool?.price?{...tool.inputSchema,properties:{...tool.inputSchema.properties,_payment:paymentInputSchema}}:tool?.inputSchema;
|
|
@@ -71,7 +74,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
71
74
|
};
|
|
72
75
|
const server=async()=>{
|
|
73
76
|
const capabilities=await selling.machineCapabilities(env);
|
|
74
|
-
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true}}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
77
|
+
const s=new McpServer({name:cat.studio?.name??'Homie studio',version:'1.0.0'},{jsonSchemaValidator:validator,capabilities:{tools:{listChanged:true},...(Object.keys(eventFeeds(env)).length?{resources:{subscribe:true},...eventsCapability}:{}),experimental:{payment:{methods:capabilities.machine?.card?{stripe:{intents:['charge']}}:{}}}}});
|
|
75
78
|
const dispatch=new Map();
|
|
76
79
|
for(const name of Object.keys(eventFeeds(env))) {
|
|
77
80
|
if(!await readableFeed(env,cat,caller,name))continue;
|