@homie-rocks/studio 0.50.6 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/app-starters/giving/README.md +25 -2
  3. package/app-starters/giving/app.json +16 -49
  4. package/app-starters/giving/index.html +1 -1
  5. package/app-starters/giving/src/rules.ts +1 -1
  6. package/app-starters/giving/src/view.ts +57 -59
  7. package/apps/APPS.md +70 -2
  8. package/apps/apps.ts +1 -1
  9. package/bin/homie-studio.mjs +8 -4
  10. package/functions/FUNCTIONS.md +39 -2
  11. package/guides/app/SKILL.md +61 -5
  12. package/guides/functions/SKILL.md +30 -0
  13. package/guides/office/SKILL.md +20 -0
  14. package/guides/servers/SKILL.md +18 -0
  15. package/guides/shop/SKILL.md +41 -20
  16. package/lib/app-needs.mjs +31 -11
  17. package/lib/app-ready.mjs +49 -0
  18. package/lib/check.mjs +51 -35
  19. package/lib/dev-stripe-fixture.mjs +37 -0
  20. package/lib/dev-stripe-sandbox.mjs +80 -0
  21. package/lib/dev.mjs +19 -17
  22. package/lib/events-cli.mjs +5 -1
  23. package/lib/local-test.mjs +18 -0
  24. package/lib/mcp-tools.mjs +15 -1
  25. package/lib/office.mjs +1 -1
  26. package/lib/scaffold.mjs +22 -1
  27. package/lib/shop-links.mjs +79 -7
  28. package/lib/shop.mjs +4 -2
  29. package/lib/stripe-connect.mjs +1 -0
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +3 -0
  32. package/package.json +1 -1
  33. package/shop/SELLING.md +2 -2
  34. package/shop/SHOP.md +120 -5
  35. package/shop/shop.ts +29 -0
  36. package/tools/tools.ts +8 -1
  37. package/worker/app-format.mjs +8 -1
  38. package/worker/app-frame.mjs +9 -0
  39. package/worker/app-records.mjs +39 -10
  40. package/worker/booking-store.mjs +79 -0
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/event-resources.mjs +30 -3
  43. package/worker/functions.mjs +5 -2
  44. package/worker/index.mjs +17 -3
  45. package/worker/local-test.mjs +19 -0
  46. package/worker/mcp-tools.mjs +19 -8
  47. package/worker/mcp.mjs +23 -11
  48. package/worker/records-page.mjs +13 -0
  49. package/worker/shop-links.mjs +3 -2
  50. package/worker/shop-page.mjs +27 -5
  51. package/worker/shop-rules.mjs +2 -1
  52. package/worker/shop-store.mjs +1 -1
  53. package/worker/shop-subscriptions.mjs +198 -0
  54. package/worker/shop.mjs +195 -29
  55. package/worker/tool-database.mjs +36 -0
  56. package/worker/version.mjs +1 -1
package/shop/SHOP.md CHANGED
@@ -108,7 +108,7 @@ If the studio is not deployed yet, approval is retained: deploy with `studio_dep
108
108
  `cloudflare_login` first if needed), then rerun connect. Webhooks require the deployed HTTPS address.
109
109
 
110
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. Tip Prices use custom amounts; free orders stay local. It creates
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
112
  an account webhook at `<site>/api/shop/hook`, captures its signing secret without displaying it,
113
113
  and installs `STRIPE_WEBHOOK_SECRET` and `STRIPE_SHOP_LINKS` through Wrangler stdin. No Stripe
114
114
  API key goes into the Worker. Repeat connect after editing shop.json: changed prices get new
@@ -132,7 +132,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
132
132
 
133
133
  | Need | Keyless behavior / fuller connection |
134
134
  |---|---|
135
- | Several different items in one payment | Buy separately. Stripe supports static multi-item links (up to 20 fixed-price lines), but they are not an arbitrary buyer cart. Dynamic carts use the keyed path. |
135
+ | Several different items in one payment | A synced menu opens several fixed-price lines; guests choose quantities on Stripe, including zero. Stripe limits Payment Links to 20 lines; larger catalogues use separate menus. Custom amounts and subscriptions keep separate links. |
136
136
  | Reserve an optional spending allowance | Requires keyed Checkout; a reusable public link cannot atomically reserve this studio's local cap. No silent weakening of a configured cap. |
137
137
  | Expire an abandoned session | Stripe controls the lifetime; deactivating a link prevents new sessions, not existing sessions. Custom expiry/cancellation requires the keyed path. |
138
138
  | Refund in one tap / player self-refund | Owner uses Stripe Dashboard or asks their Stripe-connected AI. Signed events update books. Office shows guidance instead of a paid-order refund button. Free refunds remain local. |
@@ -142,8 +142,7 @@ items; failed refunds restore the financial booking. A lost dispute revokes good
142
142
  | 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
143
  | Currency display | Payment Links always enable Stripe Adaptive Pricing. Bookkeeping uses integration currency; presentment details describe what the customer saw. |
144
144
 
145
- An existing `STRIPE_KEY` selects the fuller path automatically. Only if requested, explain:
146
- “The fuller connection adds combined carts, spending limits, automatic recovery and refunds here.”
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.”
147
146
  The explicit `shop connect --manual` path remains available, including `--live`, but requires the
148
147
  owner's existing Dashboard key step; it does not satisfy one approval. Never choose it automatically.
149
148
  Running default connect again saves links, removes the API key, and keeps signed-event fulfillment.
@@ -202,7 +201,7 @@ the full amount toward a cap. The additive schema preserves its original rows an
202
201
 
203
202
  ## Shop button on the screen
204
203
 
205
- An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
204
+ An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to disable the experience’s shop (merge with any existing screen settings). Keep it true or omitted when your app uses its own `createShop` buttons; false also refuses their checkout bridge requests. Closed shops, kids policies and an empty selection show no button.
206
205
 
207
206
  ```ts
208
207
  import { createShop } from '@homie-rocks/studio/shop';
@@ -218,3 +217,119 @@ These calls open the same shop in games and apps. Inside a player card, purchase
218
217
  Create the booking/ticket/gift intention first. `shop.buy('full-tune', 1, undefined, { reference: booking.id })` or `shop.checkout({ reference: booking.id })` sends the app's own reference through checkout. Existing boolean returns are unchanged: checkout opened is not paid. The verified durable `order.paid` event carries `data.order`, `data.reference` (null if omitted) and `data.app`. Reopened Payment Links retain the reference. A reference is buyer-supplied correlation, not authorization: verify the record, app, amount and currency, deduplicate by event/order ID, then fulfil it. Keep personal data out of references. Before Stripe is connected, local prices and unpaid requests can be demonstrated; actual payment and recurring collection remain unverified. Test events must be labelled simulations.
219
218
 
220
219
  For referrals, use Stripe Connect to split payments or a Stripe-based affiliate service; Homie builds none.
220
+
221
+ ## Keyless menus, paid seats and monthly giving (0.51.0)
222
+
223
+ Use `shop connect` by default. All objects belong to the owner's Stripe account and
224
+ are created through its approved CLI session. Homie has no platform key, OAuth
225
+ backend, payment intermediary or cut. A desktop is needed to sync, not to take money.
226
+
227
+ **A round to table 7:** declare fixed-price drinks in shop.json and run connect.
228
+
229
+ ```ts
230
+ shop.add('negroni', 2);
231
+ shop.add('spritz', 1);
232
+ await shop.checkout({ reference: 'table-7' });
233
+ ```
234
+
235
+ The default opens the synced Stripe menu containing those items. The guest chooses
236
+ final quantities **on Stripe**, starting at one for each menu line; the local cart's
237
+ quantities are not prefilled. Adjustable quantity minimum is zero (Stripe prevents
238
+ removing the last item). Payment Links support at most 20 lines. Keyless sync splits catalogues into menus of ten to fit signed invoice snapshots without authenticated pagination. Split larger menus;
239
+ if selected items span menus, the app receives `menu` and should offer separate
240
+ checkouts. Custom amounts cannot be mixed into this menu; offer a separate tip link.
241
+ Stripe invoice creation is enabled for menus (Stripe's invoice pricing applies).
242
+ Only signed paid invoice lines supply `order.paid.data.lines`, including actual
243
+ item, quantity and amount; no quantity is guessed from the total. Incomplete invoice
244
+ snapshots remain pending for recovery rather than granting a guessed order.
245
+
246
+ The opaque local order ID is `client_reference_id`; its durable reference maps back
247
+ to `table-7` in `order.paid.data.reference` and the buyer's order response. No personal
248
+ data goes in that reference. A return URL is never proof of payment.
249
+
250
+ **Book a tune; the seat is held only when paid:** declare a staff-written `slots`
251
+ collection with integer `capacity` and a server-managed `bookings` collection with
252
+ `"booking":{"slots":"slots","item":"full-tune"}`. Publish slot records, then have
253
+ 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:
255
+
256
+ ```json
257
+ [{"app":"tune","collection":"bookings","slots":"slots","slot":"sat-0900","item":"full-tune","capacity":1}]
258
+ ```
259
+
260
+ Sync after publishing or changing slots. Removed or inactive slots deactivate their
261
+ links. Each link has `restrictions.completed_sessions.limit = capacity`, with one
262
+ fixed service and one seat per checkout. Re-sync preserves Stripe's lifetime completed
263
+ count. Free slots stay local and use separate slot records from paid links. Never increment capacity just to hide a full-link error.
264
+ Refunds do not decrement Stripe's completed-session counter; the owner's AI must
265
+ review availability and resync explicitly. A changed price should use a new slot;
266
+ do not replace a sold link and accidentally reset its capacity counter.
267
+
268
+ ```ts
269
+ const result = await shop.book('full-tune', {collection:'bookings',slot:'sat-0900'});
270
+ // result.booking is the tracking record ID.
271
+ ```
272
+
273
+ Opening the link creates local `pending` state, best effort. It does **not** block
274
+ other guests. Confirmed payment creates `confirmed`; expiry/failure/refund releases
275
+ the local record. The app must say “Pay to secure your seat,” not promise an exclusive
276
+ unpaid hold. Slot reads expose `record.booking.full`, `available` and `synced` for the
277
+ UI; Stripe's full-link update and verified confirmations make the app show Full.
278
+ Events include `booking.pending`, `.confirmed`, `.released`, `.moved`; free and
279
+ explicit keyed bookings also use `.held`. Before staff moves, the owner's AI syncs **both affected slots** with `active:false`;
280
+ sync deactivates their links and expires open sessions. Wait for signed payment events,
281
+ then staff makes the versioned `{slot:...}` update. Moving into an unpaused paid target
282
+ is refused. Read fresh confirmed booking counts and sync each affected slot with
283
+ `confirmed:<count>` and `active:true`. Sync adjusts the lifetime completion budget to
284
+ `Stripe completed + capacity - confirmed` (initial publication uses capacity exactly).
285
+ This also reopens refunded availability after an explicit review. Never use a stale
286
+ confirmed count or skip pausing: local staff movement cannot mutate Stripe by itself.
287
+ An explicit keyed session connection retains exclusive expiring unpaid holds.
288
+
289
+ **Give $20 monthly:** `shop init --giving --currency CAD` includes fixed suggested
290
+ monthly gifts. Declare a recurring item, then run connect:
291
+
292
+ ```json
293
+ {"id":"monthly-gift","kind":"donation","name":"Give $20 monthly","price":2000,"interval":"month","gives":[]}
294
+ ```
295
+
296
+ `shop.buy('monthly-gift',1,undefined,{reference:'gift-20'})` opens a recurring Price
297
+ on a Payment Link (subscription mode). The giver chooses among the preset options.
298
+ Arbitrary custom monthly amounts require the explicitly selected keyed connection;
299
+ never silently request it or ask the person to find/paste a key. One-time custom
300
+ amounts remain keyless. `giving.receiptText` is the owner's receipt wording.
301
+
302
+ Signed `subscription.started`, `.renewed`, `.failed`, `.cancelled` events drive
303
+ membership and giving. Membership access ends at the paid invoice line's period end;
304
+ failed invoices cannot extend it and cancellation ends access. Unknown Prices grant
305
+ nothing. Keyless portal configuration supports cancellation, payment-method updates
306
+ and invoice history. `shop connect` enables `login_page.enabled` through the CLI API
307
+ and stores Stripe's no-code login URL; **no Dashboard toggle is required**. Givers
308
+ verify their email with Stripe to manage/cancel. The buyer-owned `/api/shop/portal`
309
+ returns this URL. Plan changes require deliberate portal configuration and fulfilment
310
+ mapping; the optional keyed path retains current-provider reconciliation for upgrades.
311
+
312
+ For offline development, import `startStripeFixture` from the installed toolkit's
313
+ `lib/dev-stripe-fixture.mjs`, start it with `{origin:'http://127.0.0.1:8787'}`, and
314
+ write its `bindings` as local Wrangler `.dev.vars` in a disposable studio. Keep it
315
+ running alongside `homie-studio dev`. The checkout page explicitly says simulation;
316
+ it takes no card and no money. Call `close()` and delete the disposable studio when
317
+ finished. This tests signed webhook plumbing, not Stripe account permissions.
318
+
319
+ The giving starter now reads the actual shop donation options with `shop.catalog()`.
320
+ `shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
321
+ opens Checkout for the selected amount and frequency. A disconnected shop displays
322
+ a disabled preview. No intention record or unverified collected total is created.
323
+ Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
324
+
325
+ For real sandbox acceptance without copying any API key, import `startKeylessStripeSandbox`
326
+ from `lib/dev-stripe-sandbox.mjs` and pass `{origin, profile, shop, bookingSlots}` with an explicit
327
+ sandbox CLI profile and loopback studio origin. It drives only that profile's
328
+ Stripe CLI, starts `stripe listen --forward-to` with the required events, and
329
+ returns local dev `bindings` and `close()`. Keep bindings private: the webhook
330
+ signing secret is real even in test mode. The keyless helper encodes link configuration for safe .dev.vars transport; do not decode or rewrite that binding. Use the helper’s `links` property for native link IDs when testing. `await helper.sync(manifest)` updates its `links` and `bindings`; save the new bindings and restart dev after a slot sync. `close()` deactivates its sandbox links and stops forwarding. Supply them to the local dev harness
331
+ only, never source control or logs. The local webhook forwarding listener needs the desktop; production uses the signed public webhook directly. No API adapter or key is installed in the keyless Worker. It never exports CLI credentials.
332
+ Use Stripe's published CLI test fixtures/tokens to complete Checkout via API;
333
+ never type card details into a form. Stop on an expired profile and ask the owner
334
+ to approve `stripe login --project-name <profile>` once. Stop the adapter and remove
335
+ dev bindings and disposable studios when finished.
package/shop/shop.ts CHANGED
@@ -44,10 +44,19 @@ export interface ShopOptions {
44
44
 
45
45
  export interface CartLine { item: string; quantity: number; amount?: number }
46
46
 
47
+ export interface ShopCatalog {
48
+ open: boolean;
49
+ currency: string;
50
+ currencyScale: number;
51
+ items: Array<{ id: string; kind: string; name: string; interval?: 'month'; price: number | 'choose'; min?: number; max?: number | null; shown: string; way: string }>;
52
+ }
53
+
47
54
  export interface Shop {
48
55
  /** Resolves once the shell answered (or after `timeoutMs` with no shell: an empty, closed shop). */
49
56
  readonly ready: Promise<ShopState>;
50
57
  readonly state: ShopState;
58
+ /** Current gift/item options and payment readiness, without exposing account details. */
59
+ catalog(): Promise<ShopCatalog>;
51
60
  /** Whether the player owns this entitlement key now ("skin:ember", "badge:supporter"). Synchronous, for a render loop. */
52
61
  has(key: string): boolean;
53
62
  /** Every key the player owns now. */
@@ -61,6 +70,8 @@ export interface Shop {
61
70
  add(item: string, quantity?: number, amount?: number): void;
62
71
  cart(): CartLine[];
63
72
  clear(): void;
73
+ /** Opens Checkout: keyless bookings are pending until paid; explicit session checkout holds capacity. The returned ID tracks the public booking record. */
74
+ book(item: string, booking: { collection: string; slot: string }, quantity?: number): Promise<{ ok: boolean; booking?: string; error?: string }>;
64
75
  checkout(options?: { reference?: string }): Promise<boolean>;
65
76
  buy(item: string, quantity?: number, amount?: number, options?: { reference?: string }): Promise<boolean>;
66
77
  /** Mark an item used; the studio's refundUsedItems policy decides whether self-service refunds still apply. */
@@ -121,6 +132,20 @@ export function createShop(opts: ShopOptions = {}): Shop {
121
132
  let cart: CartLine[] = [];
122
133
  const shop: Shop = {
123
134
  ready,
135
+ catalog: async () => {
136
+ const result: ShopCatalog = { open: false, currency: 'usd', currencyScale: 100, items: [] };
137
+ let cursor: string | undefined;
138
+ do {
139
+ const m = await send({ op: 'catalog', cursor });
140
+ if (!m.ok) return result;
141
+ result.open = Boolean(m.open);
142
+ result.currency = String(m.currency ?? 'usd');
143
+ result.currencyScale = Number(m.currencyScale) || 100;
144
+ if (Array.isArray(m.items)) result.items.push(...m.items as ShopCatalog['items']);
145
+ cursor = typeof m.nextCursor === 'string' ? m.nextCursor : undefined;
146
+ } while (cursor);
147
+ return result;
148
+ },
124
149
  get state() { return { ...state }; },
125
150
  has: (key) => owns.includes(String(key)),
126
151
  entitlements: () => owns.slice(),
@@ -129,6 +154,10 @@ export function createShop(opts: ShopOptions = {}): Shop {
129
154
  add: (item, quantity = 1, amount) => { cart.push({ item, quantity, ...(amount === undefined ? {} : { amount }) }); },
130
155
  cart: () => cart.map((line) => ({ ...line })),
131
156
  clear: () => { cart = []; },
157
+ book: async (item, booking, quantity = 1) => {
158
+ const r = await send({ op: 'checkout', booking, lines: [{ item, quantity }] });
159
+ return { ok: Boolean(r.ok), ...(typeof r.booking === 'string' ? { booking: r.booking } : {}), ...(typeof r.message === 'string' ? { error: r.message } : {}) };
160
+ },
132
161
  checkout: async (options = {}) => { const ok = Boolean((await send({ op: 'checkout', lines: cart, reference: options.reference })).ok); if (ok) cart = []; return ok; },
133
162
  buy: async (item, quantity = 1, amount, options = {}) => Boolean((await send({ op: 'checkout', reference: options.reference, lines: [{ item, quantity, ...(amount === undefined ? {} : { amount }) }] })).ok),
134
163
  close: () => { void send({ op: 'close' }); },
package/tools/tools.ts CHANGED
@@ -7,7 +7,14 @@ export interface ToolContext {
7
7
  office(path: string, body?: Record<string, unknown>, query?: Record<string, string>): Promise<unknown>;
8
8
  shop(): Promise<unknown>;
9
9
  rooms: { send(event: {game: string; room: string; event: string; data: unknown}): Promise<unknown> };
10
- database: { get(key: string): Promise<unknown>; put(key: string, value: unknown): Promise<void> };
10
+ database: {
11
+ get(key: string): Promise<unknown>;
12
+ getVersioned(key: string): Promise<{value: unknown; version: number} | null>;
13
+ put(key: string, value: unknown, options?: {version?: number}): Promise<{ok: boolean; version?: number; conflict?: boolean}>;
14
+ increment(key: string, amount?: number): Promise<{value: number; version: number}>;
15
+ append(key: string, value: unknown): Promise<{value: unknown[]; version: number}>;
16
+ list(options?: {prefix?: string; after?: string; limit?: number}): Promise<{items: {key: string; value: unknown; version: number}[]; next: string | null}>;
17
+ };
11
18
  fetch: typeof fetch;
12
19
  fetchWithSecret(name: string, url: string, init?: RequestInit): Promise<Response>;
13
20
  ai(model: string, input: unknown): Promise<unknown>;
@@ -18,7 +18,13 @@ export function appProblems(meta) {
18
18
  if (!object(meta.records) || typeof meta.records.persist !== 'boolean' || !object(meta.records.collections)) bad.push('records declares persist and collections');
19
19
  for (const [id, c] of Object.entries(collections)) {
20
20
  if (!key.test(id) || !object(c) || !object(c.fields)) { bad.push(`collection ${id}: declare fields`); continue; }
21
- for (const [field, spec] of Object.entries(c.fields)) if (!key.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field`);
21
+ for (const [field, spec] of Object.entries(c.fields)) if (!/^[a-z][a-z0-9_]*$/.test(field) || !object(spec) || !['string', 'number', 'boolean'].includes(spec.type) || (spec.enum && (!Array.isArray(spec.enum) || !spec.enum.length))) bad.push(`collection ${id}.${field}: invalid field: names start with a lowercase letter and use lowercase letters, digits and underscore; declare a string, number or boolean type`);
22
+ }
23
+ for (const [id, c] of Object.entries(collections)) if (c.booking) {
24
+ const spec = c.booking;
25
+ if (!object(spec) || !key.test(spec.slots ?? '') || !collections[spec.slots] || typeof spec.item !== 'string' || !spec.item.trim()) bad.push(`collection ${id}: booking needs slots collection and shop item`);
26
+ if (!meta.records.persist) bad.push(`collection ${id}: bookings need persistent records`);
27
+ for (const role of Object.values(roles)) if (!role.signIn && (role.can ?? []).some(cap => ['create','update','delete'].some(op => cap === `${op}:${id}` || cap === `${op}:${spec.slots}`))) bad.push(`collection ${id}: booking and slot writes require a signed-in staff role`);
22
28
  }
23
29
  for (const r of Object.values(roles)) for (const cap of Array.isArray(r?.can) ? r.can : []) if (!Object.hasOwn(meta.records?.collections ?? {}, String(cap).split(':')[1])) bad.push(`unknown collection in ${cap}`);
24
30
  for (const [k, v] of Object.entries(meta.words ?? {})) if (!/^[a-z][a-zA-Z0-9-]*$/.test(k) || typeof v !== 'string' || v.length > 500) bad.push(`words.${k}: use a short label`);
@@ -32,6 +38,7 @@ export function appRole(meta, url) {
32
38
  }
33
39
  export function recordProblem(collection, data, { create = false } = {}) {
34
40
  if (!object(data) || JSON.stringify(data).length > 8192) return 'record must be an object below 8 KB';
41
+ for (const k of Object.keys(data)) if (!/^[a-z][a-z0-9_]*$/.test(k)) return `invalid field ${k}: names start with a lowercase letter and use lowercase letters, digits and underscore`;
35
42
  for (const k of Object.keys(data)) if (!Object.hasOwn(collection.fields, k)) return `unknown field ${k}`;
36
43
  for (const [k, s] of Object.entries(collection.fields)) {
37
44
  const v = data[k];
@@ -0,0 +1,9 @@
1
+ /** Opaque frames retain their isolation. Unavailable Web Storage falls back to frame-local memory. */
2
+ export const APP_STORAGE_JS=String.raw`(()=>{
3
+ for(const name of ['localStorage','sessionStorage']) {
4
+ try {const storage=window[name];const probe='homie-storage-probe';storage.setItem(probe,'1');storage.removeItem(probe);continue;}catch{}
5
+ const data=new Map();const methods={getItem:k=>data.get(String(k))??null,setItem:(k,v)=>{data.set(String(k),String(v));},removeItem:k=>{data.delete(String(k));},clear:()=>data.clear(),key:i=>[...data.keys()][Number(i)]??null};
6
+ const storage=new Proxy(methods,{get:(target,key)=>key==='length'?data.size:key in target?target[key]:data.get(String(key)),set:(_,key,value)=>{data.set(String(key),String(value));return true;},deleteProperty:(_,key)=>data.delete(String(key)),ownKeys:()=>[...data.keys()],getOwnPropertyDescriptor:()=>({enumerable:true,configurable:true})});
7
+ Object.defineProperty(window,name,{value:storage,configurable:true});
8
+ }
9
+ })();`;
@@ -1,3 +1,6 @@
1
+ import { linkConfig } from './shop-links.mjs';
2
+ import {localSession} from './local-test.mjs';
3
+ import { moveBooking } from './booking-store.mjs';
1
4
  import { toolIdentity } from './tool-identity.mjs';
2
5
  /** Lasting app records in the studio's own D1. Room hosts never authorize writes. */
3
6
  import { appRole, recordProblem } from './app-format.mjs';
@@ -38,12 +41,14 @@ export async function appAccess(request, env, meta, url) {
38
41
  const declared = Object.hasOwn(meta.roles ?? {}, role) ? meta.roles[role] : null;
39
42
  if (!declared) return { ok: false, status: 404, error: 'unknown role' };
40
43
  if (!declared.signIn) return { ok: true, role, can: declared.can };
44
+ const session = await localSession(request, env);
45
+ if (session?.app === meta.id && session.role === role) return {ok:true,role,can:declared.can};
41
46
  if (!env.DB) return { ok: false, status: 503, error: 'app roles need the studio database' };
42
47
  const caller = toolIdentity(request);
43
48
  if (caller) {
44
49
  if (caller.owner) return { ok: true, role, can: declared.can };
45
50
  if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
46
- const grant = await env.DB.prepare('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, caller.id).first();
51
+ const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, caller.id).first();
47
52
  return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
48
53
  }
49
54
  const link = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, role).first();
@@ -51,7 +56,7 @@ export async function appAccess(request, env, meta, url) {
51
56
  if (await isOwner(request, env)) return { ok: true, role, can: declared.can };
52
57
  const person = await players.of(request, env);
53
58
  if (!person || person.guest) return { ok: false, status: 401, error: 'sign in to this studio' };
54
- const grant = await env.DB.prepare('SELECT player FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3').bind(meta.id, role, person.id).first();
59
+ const grant = await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1, '*') AND role = ?2 AND player = ?3").bind(meta.id, role, person.id).first();
55
60
  return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
56
61
  }
57
62
  export async function appRecordsRoute(request, env, meta, url, sub) {
@@ -69,39 +74,63 @@ export async function appRecordsRoute(request, env, meta, url, sub) {
69
74
  if (!ID.test(b.player)) return fail(400, 'invalid account');
70
75
  const p = await players.get(b.player, env);
71
76
  if (!p || p.guest) return fail(400, 'role grants require an existing signed-in account');
72
- await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, b.player).run();
77
+ await env.DB.prepare(b.revoke === true ? 'DELETE FROM app_roles WHERE app = ?1 AND role = ?2 AND player = ?3' : 'INSERT OR IGNORE INTO app_roles (app, role, player) VALUES (?1, ?2, ?3)').bind(b.studio === true ? '*' : meta.id, b.role, b.player).run();
73
78
  }
74
79
  const token = crypto.randomUUID().replaceAll('-', '');
75
80
  if (b.rotate === true) await env.DB.prepare('DELETE FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).run();
76
81
  await env.DB.prepare('INSERT OR IGNORE INTO app_role_links (app, role, token) VALUES (?1, ?2, ?3)').bind(meta.id, b.role, token).run();
77
82
  const row = await env.DB.prepare('SELECT token FROM app_role_links WHERE app = ?1 AND role = ?2').bind(meta.id, b.role).first();
78
- return json({ ok: true, link: `${url.origin}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
83
+ return json({ ok: true, link: `${url.origin.replace('127.0.0.1','localhost')}/${meta.id}/open?role=${b.role}&access=${row.token}`, role: b.role });
79
84
  }
80
85
  const match = /^api\/app\/records\/([a-z][a-z0-9-]{0,39})(?:\/([A-Za-z0-9_-]{1,64}))?$/.exec(sub);
86
+ if(!match && /^api\/app\/records\/[^/]+\//.test(sub))return fail(400,'record id: 1–64 letters, digits, underscore or hyphen');
81
87
  if (!match || !Object.hasOwn(meta.records?.collections ?? {}, match[1])) return fail(404, 'unknown collection');
82
88
  if (meta.records.persist !== true) return fail(409, 'use netplay state for transient records, or enable records.persist');
83
89
  const access = await appAccess(request, env, meta, url);
84
90
  if (!access.ok) return fail(access.status, access.error);
85
91
  const [, collection, id] = match;
92
+ const session = await localSession(request, env);
93
+ const testScope = (session?.app === meta.id ? session.scope : null) ?? (env.HOMIE_LOCAL_TEST_SECRET ? toolIdentity(request)?.testScope : null);
94
+ const recordApp = testScope ? `${meta.id}:check:${testScope}` : meta.id;
86
95
  const op = ({ GET: 'read', POST: 'create', PUT: 'update', DELETE: 'delete' })[request.method];
87
96
  if (!op || !access.can.includes(`${op}:${collection}`)) return fail(403, 'role cannot perform this operation');
88
97
  if (op !== 'read' && !sameOrigin(request, url) && !(appOriginOf(request) && !meta.roles[access.role].signIn)) return fail(403, 'same origin required');
89
98
  if (op === 'read') {
90
- const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(meta.id, collection, id).first()].filter(Boolean)
91
- : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(meta.id, collection).all()).results;
92
- return json({ ok: true, records: rows.map((r) => ({ id: r.id, data: JSON.parse(r.value), version: r.version, updatedAt: r.updated_at })) });
99
+ const rows = id ? [await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3').bind(recordApp, collection, id).first()].filter(Boolean)
100
+ : (await env.DB.prepare('SELECT id, value, version, updated_at FROM app_records WHERE app = ?1 AND collection = ?2 ORDER BY updated_at, id LIMIT 1000').bind(recordApp, collection).all()).results;
101
+ const records=[];
102
+ for(const r of rows) {
103
+ const record={id:r.id,data:JSON.parse(r.value),version:r.version,updatedAt:r.updated_at};
104
+ const link=linkConfig(env)?.slots?.find(l=>l.app===meta.id&&l.slots===collection&&l.slot===r.id);
105
+ if(link) {
106
+ const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-slot-link:${link.id}`).first();
107
+ const observed=saved?JSON.parse(saved.value):null;
108
+ const status=observed && observed.capacity===(link.limit??link.capacity) ? observed : link;
109
+ const count=await env.DB.prepare("SELECT COALESCE(SUM(quantity),0) n FROM shop_bookings WHERE app=?1 AND slots=?2 AND slot=?3 AND state='confirmed'").bind(recordApp,collection,r.id).first();
110
+ record.booking={full:!status.active||status.count>=(link.limit??link.capacity)||count.n>=link.capacity,available:status.active?Math.max(0,Math.min(link.capacity-count.n,(link.limit??link.capacity)-status.count)):0,synced:link.capacity===record.data.capacity};
111
+ }
112
+ records.push(record);
113
+ }
114
+ return json({ok:true,records});
93
115
  }
94
116
  if (!id) return fail(400, 'record id required');
95
117
  const body = await bodyOf(request);
118
+ if (meta.records.collections[collection].booking) {
119
+ if (op !== 'update' || !meta.roles[access.role].signIn) return fail(403, 'bookings are created and released through checkout');
120
+ if (!Number.isSafeInteger(body.version) || !ID.test(body.data?.slot ?? '') || Object.keys(body.data ?? {}).some(k => k !== 'slot')) return fail(400, 'move requires slot and current version');
121
+ const moved = await moveBooking(env, { app: recordApp, collection, id, slot: body.data.slot, version: body.version });
122
+ return moved ? json({ ok: true, id, version: body.version + 1 }) : fail(409, 'booking changed or is released; reload');
123
+ }
96
124
  if (op !== 'delete') { const bad = recordProblem(meta.records.collections[collection], body.data, { create: op === 'create' }); if (bad) return fail(400, bad); }
97
125
  if (op !== 'create' && (!Number.isSafeInteger(body.version) || body.version < 1)) return fail(400, 'record version required');
98
126
  const stmt = op === 'create'
99
- ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(meta.id, collection, id, JSON.stringify(body.data), Date.now())
100
- : op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(meta.id, collection, id, JSON.stringify(body.data), Date.now(), body.version)
101
- : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(meta.id, collection, id, body.version);
127
+ ? env.DB.prepare('INSERT OR IGNORE INTO app_records (app, collection, id, value, version, updated_at) SELECT ?1, ?2, ?3, ?4, 1, ?5 WHERE (SELECT COUNT(*) FROM app_records WHERE app = ?1 AND collection = ?2) < 1000').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now())
128
+ : op === 'update' ? env.DB.prepare('UPDATE app_records SET value = ?4, version = version + 1, updated_at = ?5 WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?6').bind(recordApp, collection, id, JSON.stringify(body.data), Date.now(), body.version)
129
+ : env.DB.prepare('DELETE FROM app_records WHERE app = ?1 AND collection = ?2 AND id = ?3 AND version = ?4').bind(recordApp, collection, id, body.version);
102
130
  const result = await stmt.run();
103
131
  return result.meta.changes ? json({ ok: true, id, version: op === 'create' ? 1 : body.version + 1 }) : fail(409, 'record changed, already exists, or collection full; reload');
104
132
  } catch (error) {
133
+ if (/booking capacity|capacity below|slot has active/.test(error.message)) return fail(409, error.message);
105
134
  if (error instanceof SyntaxError || error.message === 'body too large') return fail(400, error.message);
106
135
  return fail(503, 'app database unavailable; apply studio migrations');
107
136
  }
@@ -0,0 +1,79 @@
1
+ import { linkConfig, hasStripeKey } from './shop-links.mjs';
2
+ /** Capacity belongs to the studio database. Checkout expiry is a provider fact,
3
+ * never an inference from a browser return or the local clock. */
4
+ export const BOOKINGS_MIGRATION_FILE = '0025_bookings.sql';
5
+ export const BOOKINGS_MIGRATION = `
6
+ CREATE TABLE shop_bookings (
7
+ id TEXT PRIMARY KEY, app TEXT NOT NULL, collection TEXT NOT NULL,
8
+ slots TEXT NOT NULL, slot TEXT NOT NULL, quantity INTEGER NOT NULL CHECK(quantity > 0),
9
+ order_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL DEFAULT 'held'
10
+ CHECK(state IN ('pending','held','confirmed','released')),
11
+ version INTEGER NOT NULL DEFAULT 1, updated_at INTEGER NOT NULL
12
+ );
13
+ CREATE INDEX shop_bookings_capacity ON shop_bookings(app, slots, slot, state);
14
+ CREATE TRIGGER booking_capacity_insert BEFORE INSERT ON shop_bookings WHEN NEW.state != 'pending' BEGIN
15
+ SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
16
+ SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
17
+ AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
18
+ (SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND state IN ('held','confirmed'))
19
+ );
20
+ END;
21
+ CREATE TRIGGER booking_capacity_move BEFORE UPDATE OF slot ON shop_bookings
22
+ WHEN NEW.state IN ('held','confirmed') BEGIN
23
+ SELECT RAISE(ABORT,'booking capacity unavailable') WHERE NOT EXISTS (
24
+ SELECT 1 FROM app_records s WHERE s.app=NEW.app AND s.collection=NEW.slots AND s.id=NEW.slot
25
+ AND json_type(s.value,'$.capacity')='integer' AND json_extract(s.value,'$.capacity') >= NEW.quantity +
26
+ (SELECT COALESCE(SUM(quantity),0) FROM shop_bookings WHERE app=NEW.app AND slots=NEW.slots AND slot=NEW.slot AND id!=NEW.id AND state IN ('held','confirmed'))
27
+ );
28
+ END;
29
+ CREATE TRIGGER booking_slot_update BEFORE UPDATE OF value ON app_records
30
+ WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
31
+ SELECT RAISE(ABORT,'capacity below existing bookings') WHERE json_type(NEW.value,'$.capacity') IS NOT 'integer' OR json_extract(NEW.value,'$.capacity') <
32
+ (SELECT SUM(quantity) FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed'))
33
+ ;
34
+ END;
35
+ CREATE TRIGGER booking_slot_delete BEFORE DELETE ON app_records
36
+ WHEN EXISTS (SELECT 1 FROM shop_bookings WHERE app=OLD.app AND slots=OLD.collection AND slot=OLD.id AND state IN ('held','confirmed')) BEGIN
37
+ SELECT RAISE(ABORT,'slot has active bookings');
38
+ END;
39
+ CREATE TRIGGER booking_order_state AFTER UPDATE OF status ON shop_orders BEGIN
40
+ UPDATE shop_bookings SET state=CASE WHEN NEW.status='paid' THEN 'confirmed' ELSE 'released' END,
41
+ version=version+1, updated_at=NEW.updated_at
42
+ WHERE order_id=NEW.id AND ((state IN ('pending','held') AND NEW.status IN ('paid','expired','failed')) OR (state IN ('pending','held','confirmed') AND NEW.status IN ('refunded','lost')));
43
+ END;
44
+ ${['INSERT', 'UPDATE'].map(op => `CREATE TRIGGER booking_record_${op.toLowerCase()} AFTER ${op} ON shop_bookings BEGIN
45
+ INSERT INTO app_records(app,collection,id,value,version,updated_at)
46
+ VALUES(NEW.app,NEW.collection,NEW.id,json_object('slot',NEW.slot,'quantity',NEW.quantity,'state',NEW.state),NEW.version,NEW.updated_at)
47
+ ON CONFLICT(app,collection,id) DO UPDATE SET value=excluded.value,version=excluded.version,updated_at=excluded.updated_at;
48
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
49
+ SELECT 'booking:' || NEW.id || ':' || NEW.version,'booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"},
50
+ json_object('booking',NEW.id,'app',NEW.app,'collection',NEW.collection,'slot',NEW.slot,'state',NEW.state,'quantity',NEW.quantity,'order',NEW.order_id,'version',NEW.version),NEW.updated_at
51
+ WHERE EXISTS(SELECT 1 FROM function_subscriptions WHERE type='booking.' || ${op === 'INSERT' ? "NEW.state" : "CASE WHEN OLD.slot!=NEW.slot THEN 'moved' ELSE NEW.state END"});
52
+ END;`).join('\n')}
53
+ DROP TRIGGER IF EXISTS shop_orders_function_paid;
54
+ CREATE TRIGGER shop_orders_function_paid AFTER UPDATE ON shop_orders
55
+ WHEN EXISTS (SELECT 1 FROM function_subscriptions WHERE type='order.paid')
56
+ AND OLD.paid_at IS NULL AND NEW.paid_at IS NOT NULL
57
+ AND NOT EXISTS (SELECT 1 FROM purchase_orders WHERE id=NEW.id) BEGIN
58
+ INSERT OR IGNORE INTO studio_events(id,type,data,at)
59
+ VALUES('shop_orders:' || NEW.id || ':paid','order.paid',
60
+ json_object('order',NEW.id,'source','shop_orders','amount',NEW.amount,'currency',NEW.currency,'mode',NEW.mode,
61
+ 'app',NEW.game,'reference',(SELECT value FROM meta WHERE key='shop-reference:' || NEW.id),
62
+ 'lines',json((SELECT json_group_array(json_object('item',item,'quantity',quantity,'amount',amount,'total',total)) FROM shop_order_lines WHERE order_id=NEW.id))),NEW.paid_at);
63
+ END;
64
+ `;
65
+
66
+ export function bookingStatement(env, { id, app, collection, slots, slot, quantity, order, pending = false, now = Date.now() }) {
67
+ return env.DB.prepare(`INSERT INTO shop_bookings(id,app,collection,slots,slot,quantity,order_id,updated_at,state)
68
+ SELECT ?1,?2,?3,?4,?5,?6,?7,?8,?9 WHERE EXISTS(SELECT 1 FROM shop_orders WHERE id=?7)`).bind(id, app, collection, slots, slot, quantity, order, now, pending ? 'pending' : 'held');
69
+ }
70
+
71
+ /** Staff movement uses the same version contract as app records. */
72
+ export async function moveBooking(env, { app, collection, id, slot, version }) {
73
+ const target=linkConfig(env)?.slots?.find(l=>l.app===app&&l.collection===collection&&l.slot===slot);
74
+ if(!hasStripeKey(env) && target && !target.paused)throw new Error('booking capacity: ask the owner’s AI to pause the target slot and expire open links before moving; then sync confirmed seat counts');
75
+ const result = await env.DB.prepare(`UPDATE shop_bookings SET slot=?4,version=version+1,updated_at=?6
76
+ WHERE app=?1 AND collection=?2 AND id=?3 AND version=?5 AND state IN ('held','confirmed')`)
77
+ .bind(app, collection, id, slot, version, Date.now()).run();
78
+ return result.meta.changes > 0;
79
+ }
@@ -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.50.6","sha256":"8aabf4298c649e24766b5dbd5836625be78a4c69c1005c221d93d4c9e4e45b3e"});
2
+ export const STUDIO_BUILD = Object.freeze({"version":"0.51.0","sha256":"5a8e0df9f204fbe4978d618b507fe5ae0c5005ef4f106ac4b78dfac5f6c02b7b"});
@@ -17,7 +17,7 @@ export async function readableFeed(env,cat,caller,name) {
17
17
  if(!app||!app.records?.collections?.[feed.collection])return false;
18
18
  if(caller.owner)return true;
19
19
  if(launchOf(app,await settingsOf(env,{fresh:true}),env)!=='public')return false;
20
- return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||caller.roles?.[app.id]?.includes(role)));
20
+ return Object.entries(app.roles??{}).some(([role,decl])=>decl.can.includes('read:'+feed.collection)&&(!decl.signIn||(caller.roles?.[app.id]?.includes(role)||caller.roles?.['*']?.includes(role))));
21
21
  }
22
22
  // Non-record events have no app read capability; they remain owner-only.
23
23
  return caller.owner===true;
@@ -56,12 +56,14 @@ export async function pushEvents(env,cat,origin) {
56
56
  }
57
57
 
58
58
  /** One existing Lobby object per OAuth connection: streams never live in a Worker isolate. */
59
+ const streamCaller=(env,connection)=>connection.startsWith('public:')?Promise.resolve({id:null,owner:false,client:null,connection}):currentCaller(env,{connection});
59
60
  export class EventStreams {
60
61
  constructor(env,ctx){this.env=env;this.ctx=ctx;this.stream=null;this.tail=Promise.resolve();}
61
- close(stream){stream.cancelled=true;stream.resume?.();try{stream.controller.close();}catch{}if(this.stream===stream)this.stream=null;}
62
+ 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(()=>{}));}}
62
63
  background(input){this.ctx.waitUntil(this.wake(input).catch(error=>{if(this.stream)this.close(this.stream);console.error('Event stream delivery failed',error);}));}
63
64
  async fetch(request) {
64
65
  const input=await request.json();
66
+ if(input.op==='tools') {await this.wake(input);return new Response(null,{status:204});}
65
67
  if(input.op==='stream') {
66
68
  // A reconnect replaces the previous GET, including a half-open socket.
67
69
  if(this.stream)this.close(this.stream);
@@ -73,6 +75,10 @@ export class EventStreams {
73
75
  const stream={...input,after,name,controller:null,cancelled:false,resume:null};
74
76
  const body=new ReadableStream({start:controller=>{stream.controller=controller;controller.enqueue(new TextEncoder().encode(': connected\n\n'));},pull:()=>{const resume=stream.resume;stream.resume=null;resume?.();},cancel:()=>this.close(stream)});
75
77
  this.stream=stream;
78
+ const caller=await streamCaller(this.env,connection).catch(()=>null);
79
+ if(!caller){this.close(stream);return new Response('Unauthorized',{status:401});}
80
+ stream.toolSignature=await this.toolSignature(input.tools??[],caller);
81
+ 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();
76
82
  // Replay and later wakes share one ordered pump. Backpressure must not
77
83
  // hold up the writer's request or a function handler.
78
84
  this.background(input);
@@ -81,11 +87,16 @@ export class EventStreams {
81
87
  this.background(input);return new Response(null,{status:204});
82
88
  }
83
89
  wake(input){const work=this.tail.then(()=>this.deliver(input));this.tail=work.catch(()=>{});return work;}
90
+ async toolSignature(tools,caller) {const visible=[];for(const tool of tools)if(await allowed(tool.audience,caller,this.env))visible.push(tool);return JSON.stringify(visible);}
84
91
  async deliver(input) {
85
92
  const stream=this.stream;if(!stream)return;
86
93
  let caller;
87
- try{caller=await currentCaller(this.env,{connection:input.connection});}
94
+ try{caller=await streamCaller(this.env,input.connection);}
88
95
  catch{this.close(stream);return;}
96
+ if(input.tools){
97
+ const signature=await this.toolSignature(input.tools,caller);
98
+ 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'));}
99
+ }
89
100
  const rows=(await this.env.DB.prepare("SELECT value FROM mcp_kv WHERE key LIKE ?1").bind('event-sub:'+caller.connection+':%').all()).results;
90
101
  const subscriptions=[];
91
102
  for(const row of rows){const sub=JSON.parse(row.value);if(await readableFeed(this.env,input.cat,caller,sub.name))subscriptions.push(sub);}
@@ -114,3 +125,19 @@ export class EventStreams {
114
125
  }
115
126
  }
116
127
  }
128
+
129
+ /** Refresh active streams whenever this Worker observes a new tool catalogue. */
130
+ export async function pushToolList(env,cat,tools,origin) {
131
+ if(!env.LOBBY)return;
132
+ const publicShape=tools.map(({name,description,inputSchema,audience,kind})=>({name,description,inputSchema,audience,kind}));
133
+ const signature=JSON.stringify(publicShape);
134
+ const previous=await env.DB.prepare("SELECT value FROM mcp_kv WHERE key='tools-catalogue'").first();
135
+ if(previous?.value===signature)return;
136
+ 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();
137
+ let after='';
138
+ for(;;){
139
+ const rows=(await env.DB.prepare("SELECT key,value FROM mcp_kv WHERE key LIKE 'tools-stream:%' AND key>?1 ORDER BY key LIMIT 100").bind(after).all()).results;
140
+ for(const row of rows){const connection=JSON.parse(row.value).connection;await eventHub(env,connection).fetch('https://events/__studio_events',{method:'POST',body:JSON.stringify({op:'tools',connection,cat,tools:publicShape,origin})});}
141
+ if(rows.length<100)break;after=rows.at(-1).key;
142
+ }
143
+ }
@@ -74,8 +74,11 @@ export async function runFunctions(env, cat, origin, {definitions, now = Date.no
74
74
  if (!claimed.meta.changes) continue;
75
75
  let failure=null;
76
76
  try {
77
- const event=Object.freeze({id:row.event,type:row.type,at:row.at,data:JSON.parse(row.data),attempt:row.attempts+1});
78
- await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null},'function:'+fn.name,fn));
77
+ const stored=JSON.parse(row.data);
78
+ const isolated=env.HOMIE_LOCAL_TEST_SECRET && /^([a-z][a-z0-9-]*):check:([a-zA-Z0-9-]+)$/.exec(stored?.app??'');
79
+ 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
+ const testScope=isolated?isolated[2]:null;
81
+ await fn.handler(event,services(env,cat,origin,{id:'studio-function',owner:true,client:null,...(testScope?{testScope}:{})},'function:'+fn.name+(testScope?':check:'+testScope:''),fn));
79
82
  } catch(error) {failure=fn.webhook?webhookFailure(error):functionError(error,env);}
80
83
  const finished=now();
81
84
  await env.DB.batch([