@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.
Files changed (91) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -29
  3. package/apps/APPS.md +1 -1
  4. package/bin/homie-studio.mjs +28 -30
  5. package/docs/mcp-events.md +68 -0
  6. package/guides/app/SKILL.md +25 -1
  7. package/guides/art/SKILL.md +68 -140
  8. package/guides/art/scripts/art.mjs +6 -79
  9. package/guides/models/SKILL.md +73 -177
  10. package/guides/models/references/RIGHTS.md +1 -1
  11. package/guides/models/scripts/models.mjs +7 -288
  12. package/guides/music/SKILL.md +74 -183
  13. package/guides/music/references/COMPOSITION.md +21 -26
  14. package/guides/music/references/GAME-AUDIO.md +3 -3
  15. package/guides/music/references/RIGHTS.md +12 -15
  16. package/guides/music/scripts/music.mjs +11 -202
  17. package/guides/office/SKILL.md +24 -0
  18. package/guides/parts/SKILL.md +1 -1
  19. package/guides/servers/SKILL.md +24 -0
  20. package/guides/shop/SKILL.md +68 -77
  21. package/guides/sound/SKILL.md +70 -1
  22. package/guides/sound/scripts/player/sound.js +233 -0
  23. package/guides/studio-setup/SKILL.md +11 -27
  24. package/guides/studio-setup/references/OFFICIAL-MEDIA.md +66 -0
  25. package/guides/style/SKILL.md +66 -3
  26. package/guides/video/SKILL.md +83 -0
  27. package/guides/video/references/DELIVERY.md +73 -0
  28. package/guides/video/references/HONESTY.md +40 -0
  29. package/guides/video/references/METHOD.md +144 -0
  30. package/guides/video/references/RECORD.md +137 -0
  31. package/guides/video/references/TRAILER.md +154 -0
  32. package/guides/video/references/examples/site-walkthrough.json +14 -0
  33. package/guides/video/references/examples/studio-play-phone.json +15 -0
  34. package/guides/video/references/examples/studio-play.json +17 -0
  35. package/guides/video/scripts/capture-game.mjs +175 -0
  36. package/guides/video/scripts/clock.js +136 -0
  37. package/guides/video/scripts/drawover/film.js +58 -0
  38. package/guides/video/scripts/drawover/index.html +9 -0
  39. package/guides/video/scripts/drawover/look.js +115 -0
  40. package/guides/video/scripts/drawover/shots.json +12 -0
  41. package/guides/video/scripts/lib/browser.mjs +87 -0
  42. package/guides/video/scripts/lib/frames.mjs +170 -0
  43. package/guides/video/scripts/lib/remix.mjs +159 -0
  44. package/guides/video/scripts/qa.mjs +127 -0
  45. package/guides/video/scripts/record-fixed.mjs +403 -0
  46. package/guides/video/scripts/record-page.mjs +477 -0
  47. package/guides/video/scripts/tap.js +108 -0
  48. package/guides/video/scripts/video.mjs +888 -0
  49. package/lib/build.mjs +12 -9
  50. package/lib/cloudflare.mjs +6 -11
  51. package/lib/dev.mjs +1 -2
  52. package/lib/doctor.mjs +10 -54
  53. package/lib/functions-build.mjs +8 -1
  54. package/lib/mcp-tools.mjs +42 -21
  55. package/lib/mcp.mjs +1 -1
  56. package/lib/media-import.mjs +41 -0
  57. package/lib/media-workflows.mjs +13 -0
  58. package/lib/official-media.mjs +15 -0
  59. package/lib/rules-build.mjs +13 -5
  60. package/lib/scaffold.mjs +2 -2
  61. package/lib/shop-links.mjs +54 -18
  62. package/lib/shop-listen.mjs +43 -0
  63. package/lib/shop-local.mjs +1 -1
  64. package/lib/shop-mcp.mjs +58 -0
  65. package/lib/stripe-connect.mjs +36 -15
  66. package/lib/template-history.json +87 -0
  67. package/lib/upgrade-media.mjs +13 -0
  68. package/lib/upgrade.mjs +10 -0
  69. package/package.json +1 -1
  70. package/parts/PARTS.md +1 -1
  71. package/rules/view.ts +4 -0
  72. package/shop/SHOP.md +66 -34
  73. package/worker/build-id.mjs +1 -1
  74. package/worker/event-resources.mjs +12 -3
  75. package/worker/event-webhooks.mjs +14 -9
  76. package/worker/functions.mjs +11 -6
  77. package/worker/index.mjs +5 -1
  78. package/worker/mcp-event-streams.mjs +58 -0
  79. package/worker/mcp-events-wire.mjs +41 -0
  80. package/worker/mcp-events.mjs +132 -0
  81. package/worker/mcp.mjs +4 -1
  82. package/worker/purchase-reconciliation.mjs +16 -1
  83. package/worker/shop-subscriptions.mjs +2 -0
  84. package/worker/shop.mjs +7 -5
  85. package/worker/stripe-ownership.mjs +7 -0
  86. package/worker/version.mjs +1 -1
  87. package/guides/models/references/models.json +0 -175
  88. package/guides/music/scripts/lib/eleven.mjs +0 -260
  89. package/lib/projects-env.mjs +0 -68
  90. package/lib/projects.mjs +0 -311
  91. 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
- `shop connect` (MCP `stripe_login`) starts Stripe's official browser approval, sandbox/test first.
105
- If Stripe CLI is missing, the result gives `npm install -g @stripe/cli@latest` for the AI to run.
106
- The person never supplies a key to the AI. A studio-specific CLI profile avoids borrowing another studio's login.
107
- If the studio is not deployed yet, approval is retained: deploy with `studio_deploy` (and
108
- `cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
109
-
110
- **The default is keyless.** Connect syncs one Product and Price for each shop.json item and one
111
- Payment Link for each paid item, adjustable-quantity menus, and one capacity-limited link per published booking slot. Tip Prices use custom amounts; free orders stay local. It creates
112
- an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
113
- and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
114
- API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
115
- Price versions, changed links get new versions, and removed objects are archived. Old paid orders
116
- keep their frozen item definitions. Receipts contain no credentials. Unchanged runs create nothing.
117
-
118
- With an existing account and deployed studio, the person approves Stripe CLI once; an already
119
- connected profile needs zero new approvals. An MCP-only grant is separate and is never extracted.
120
- The AI installs tooling, creates/syncs objects and stores the secret. New account verification,
121
- activation, administrator access settings and Cloudflare authorization are separate prerequisites.
122
- `shop connect --live` requires the owner's go-live request. `--renew` retries browser authorization.
123
- Never request a key from the person for the default flow.
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
- A key alone never selects the fuller path. It requires explicit `STRIPE_CHECKOUT_MODE=sessions` as well as a Worker key. Only if requested, explain: “The optional session connection adds exact app-built carts, spending reservations, expiring holds and runtime refunds.”
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 `shop connect` by default. All objects belong to the owner's Stripe account and
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
- `shop connect`, or call local MCP `stripe_bookings_sync` with the same `slots` array:
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. `shop connect` enables `login_page.enabled` through the CLI API
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
@@ -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.3","sha256":"ac8867ee4a8f906ed64a106d28d60921552d1caad2d99dd70467ae42e70aea75"});
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 body=JSON.stringify({id:event.id,type:event.type,at:event.at,data:event.data});
29
- const signature=btoa(String.fromCharCode(...new Uint8Array(await crypto.subtle.sign('HMAC',key,new TextEncoder().encode(`${id}.${timestamp}.${body}`)))));
30
- const controller=new AbortController();const timer=setTimeout(()=>controller.abort(),timeoutMs);
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(hook.url,{method:'POST',redirect:'manual',signal:controller.signal,headers:{'content-type':'application/json','webhook-id':id,'webhook-timestamp':timestamp,'webhook-signature':'v1,'+signature},body});
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)throw new WebhookFailure('Webhook HTTP '+response.status);
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,
@@ -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
- return (preview && !shopOf(cat)?.broken || await sellingShop(env, cat)) ? shellShop(cat, game, options) : null;
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'&&params[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&&params.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;