@homie-rocks/studio 0.51.8 → 0.52.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 (52) hide show
  1. package/CHANGELOG.md +32 -0
  2. package/apps/APPS.md +117 -0
  3. package/apps/apps.ts +51 -1
  4. package/docs/toolkit-place-patterns.md +6 -0
  5. package/functions/FUNCTIONS.md +27 -0
  6. package/functions/functions.ts +4 -0
  7. package/guides/app/PLACES.md +123 -0
  8. package/guides/app/SKILL.md +3 -0
  9. package/guides/game/SKILL.md +3 -0
  10. package/guides/shop/SKILL.md +27 -12
  11. package/lib/app-needs.mjs +41 -6
  12. package/lib/build.mjs +5 -0
  13. package/lib/check.mjs +6 -6
  14. package/lib/cloudflare.mjs +2 -2
  15. package/lib/dev-restart.mjs +25 -0
  16. package/lib/dev.mjs +6 -1
  17. package/lib/functions-build.mjs +3 -0
  18. package/lib/mcp-tools.mjs +2 -2
  19. package/lib/scaffold.mjs +22 -1
  20. package/lib/shop-links.mjs +4 -4
  21. package/lib/shop-mcp.mjs +82 -14
  22. package/lib/stripe-connect.mjs +9 -4
  23. package/lib/template-history.json +42 -0
  24. package/lib/upgrade-places.mjs +49 -0
  25. package/lib/upgrade.mjs +2 -1
  26. package/lib/worker-config.mjs +6 -3
  27. package/links/links.d.mts +5 -0
  28. package/links/links.d.ts +2 -0
  29. package/links/links.mjs +2 -0
  30. package/links/time.mjs +20 -0
  31. package/package.json +1 -1
  32. package/shop/SHOP.md +27 -12
  33. package/shop/shop.ts +1 -1
  34. package/tools/tools.ts +12 -0
  35. package/worker/app-records.mjs +5 -1
  36. package/worker/app-shell.mjs +27 -4
  37. package/worker/app-streams.mjs +57 -0
  38. package/worker/app-tools.mjs +44 -0
  39. package/worker/booking-holds.mjs +87 -0
  40. package/worker/build-id.mjs +1 -1
  41. package/worker/business-events.mjs +31 -0
  42. package/worker/event-resources.mjs +1 -1
  43. package/worker/functions.mjs +5 -0
  44. package/worker/index.mjs +19 -4
  45. package/worker/mcp-events.mjs +2 -2
  46. package/worker/mcp-tools.mjs +3 -0
  47. package/worker/mcp.mjs +3 -3
  48. package/worker/pages.mjs +4 -4
  49. package/worker/records-page.mjs +2 -2
  50. package/worker/shop-links.mjs +12 -0
  51. package/worker/shop.mjs +14 -4
  52. package/worker/version.mjs +1 -1
package/CHANGELOG.md CHANGED
@@ -17,6 +17,38 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
17
17
  `npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
18
18
  file) and what the upgrade would change, and changes nothing until you agree.
19
19
 
20
+ ## [0.52.0] - 2026-10-15
21
+
22
+ **Plugin 0.52.0**
23
+
24
+ Run your place with trusted actions across screens and AI, live business records, shared booking capacity and stronger proofs that it works for your customers.
25
+
26
+ ### Added
27
+
28
+ - **One trusted action from any screen or AI:** App screens call the same studio tools as MCP through `createAppTools`, with strict input schemas, server role checks and versioned writes.
29
+ - **Screens that stay in step:** Live record subscriptions and presence use the existing Lobby without polling or a dummy game room. Supported app context supplies your public address and a local proof clock; isolated proofs stay separate.
30
+ - **Business events with useful history:** Record changes include the previous value. Trusted functions publish schema-checked business feeds with durable receipts that prevent duplicate events.
31
+ - **Bookings that share one capacity:** App holds and Stripe session checkout use the same capacity row, including group quantities, ownership, version checks and scheduled release. Confirmations distinguish unpaid reservations from verified payments.
32
+ - **Proofs for difficult situations:** Needs can force disabled clicks to check server refusals, race actions from separate browsers, restart local development and reload without WebGL.
33
+ - **Time at your place:** IANA time zones support local scheduling and explicitly reject nonexistent or ambiguous wall times around clock changes.
34
+
35
+ ### Fixed
36
+
37
+ - **Shops that survive upgrades:** Matching 0.51.6 Stripe plugin verifications remain valid; real shop, slot or site changes explain what needs verification. Unfinished setup still builds and deploys with checkout closed and a clear next step.
38
+ - **Room for your whole catalogue:** Verified public links ship as a JSON asset instead of a size-limited Worker text binding, including catalogues with more than 100 links. Missing assets keep checkout closed.
39
+ - **Faster Stripe setup and replanning:** Saved public observations avoid repeated discovery. Explicit refresh paginates expanded Payment Link lists, refreshes changed links and preserves verification freshness. Private webhook setup installs Stripe's official npm CLI automatically when missing, then continues to browser approval.
40
+
41
+ ### Changed
42
+
43
+ - **Guides grounded in six real builds:** App and game guides include place patterns, security alternatives and small examples that compile against the toolkit.
44
+ - **Cleaner upgrades:** Recognized polling workarounds and exact obsolete capability notes are retired; custom business logic remains available for review.
45
+
46
+ ### Upgrade notes
47
+
48
+ - Apply migrations `0027_app_live.sql`, `0028_business_events.sql` and `0029_booking_holds.sql` in order.
49
+ - Replace refresh timers with record subscriptions, and private origin, peer and clock reads with `createAppContext`.
50
+ - Shared local booking holds require Stripe session checkout. Keyless Payment Links cannot claim those holds and are explicitly refused.
51
+
20
52
  ## [0.51.8] - 2026-10-15
21
53
 
22
54
  **Plugin 0.51.8**
package/apps/APPS.md CHANGED
@@ -222,3 +222,120 @@ and pass a role when deliberately testing the customer experience.
222
222
  `check --shots` may capture outside the studio, but persistent needs evidence copies
223
223
  those pictures into `.studio/checks` and stores studio-relative paths. Keep that
224
224
  folder with the Codex evidence. Deploy can render it without outside-path warnings.
225
+
226
+ ## Screens and AI share tools
227
+
228
+ Import `createAppTools` from `@homie-rocks/studio/apps`, then call
229
+ `await tools.call('place_book', {slot, request})`. It runs the studio's `defineTool`
230
+ handler, with its strict input schema, audience checks, rate policy and versioned
231
+ `context.database`. The shell supplies identity; message fields cannot grant a
232
+ role. Only studio-defined tools are exposed. Priced tools still use MCP's payment
233
+ approval flow. Dispose the client on unmount. Use a stable request ID across
234
+ retries; a lost response does not mean the write failed. Keep rules in a shared
235
+ `domain.ts`, but only the server handler commits them.
236
+
237
+ ## Live records and frame context
238
+
239
+ `records.subscribe(() => void refresh())` returns an unsubscribe function. It
240
+ notifies on connection/reconnection and when the collection changes; `refresh`
241
+ calls `records.list()` to obtain currently authorized data. Do an initial read as
242
+ well. Coalesce overlapping refreshes in a busy screen. This uses the existing
243
+ Lobby, without a rules file or a dummy map. Notifications contain collection names,
244
+ not records or private IDs. A 15-second server heartbeat repairs missed wakes;
245
+ screens do not poll. Closing the page closes its stream. Apply `0027_app_live.sql`
246
+ on upgrade before starting the new Worker.
247
+
248
+ `createAppContext()` provides `ready`, `current`, `subscribe`, `now()` and `dispose`.
249
+ After `await context.ready`, `current` has `app`, `origin`, public `url`, and
250
+ `presence` (connected screens in this app/room and isolated proof scope, including
251
+ the current screen). Presence is screens, not unique people or paid customers.
252
+ The public address excludes role grants and booking credentials. Do not inspect
253
+ `HOMIE_NET` or cast room internals to read peers.
254
+
255
+ For a signed local proof session, the shell supports
256
+ `window.homieApp.setTestClock(epochMilliseconds)` and `null` to restore wall time;
257
+ frame code reads `context.now()`. Production shells refuse the setter. This is a
258
+ view clock, never authority for capacity, payment or server deadlines. Scheduled
259
+ needs supply their own explicit `scheduledTime`.
260
+
261
+ See `guides/app/PLACES.md` for the six-build recipe and small typed examples.
262
+
263
+ ## One capacity budget for holds and Stripe
264
+
265
+ A collection with `booking: {slots:'slots', item:'ticket'}` uses the same
266
+ `shop_bookings` row from hold through payment. A trusted tool can call:
267
+
268
+ ```ts
269
+ const held = await context.bookings.hold({
270
+ app: 'place', collection: 'bookings', slot: 'morning', quantity: 2,
271
+ id: input.request, expiresAt: Date.now() + 60_000 // choose the hold window on the server
272
+ });
273
+ ```
274
+
275
+ Require a signed-in caller, or pass a private, random 32–128 hex-character `token`
276
+ for a guest. Keep the request ID stable on retries; a retry returns the original expiry and never extends it. The returned `id` is
277
+ scoped by app/collection/proof; it is public identification, never authorization.
278
+ Only the owner (or holder of that private token) can `get`, `confirm` or `release`.
279
+ Confirm/release take `{id, version, token?}`. Another owner's ID is not permission.
280
+ The entire group quantity reserves atomically or fails; there is no partial group.
281
+ Slot reads report availability including held and confirmed quantities.
282
+
283
+ Use `shop.book('ticket', {collection:'bookings', slot:'morning', hold:held.id,
284
+ token}, 2)` to pay for that same row. Session checkout claims it once, in the order
285
+ transaction. A second checkout cannot take it; verified payment updates it.
286
+ `confirmed` means a capacity commitment; only `paid:true` means payment was
287
+ verified. Do not label a confirmation as a paid receipt.
288
+
289
+ A scheduled owner function calls
290
+ `context.bookings.releaseExpired({app:'place',collection:'bookings',now:event.data.scheduledTime})`.
291
+ Only unconfirmed local holds expire this way. After checkout claims a hold,
292
+ Stripe's verified payment/expiry/refund owns its lifecycle; a local timer cannot
293
+ release a possibly paid seat. Keyless Payment Links cannot safely claim local
294
+ holds: configure session checkout, or keep using their existing pending-to-paid
295
+ path. The toolkit refuses to mix active keyless links with new local holds.
296
+ Apply `0029_booking_holds.sql`; public projections never include actors or tokens.
297
+
298
+ ## Adversarial needs
299
+
300
+ Actions inside `concurrent` start together on separate browser contexts:
301
+
302
+ ```json
303
+ {"concurrent":[{"surface":"phone","selector":"#book","click":true},{"surface":"phone-two","selector":"#book","click":true}]}
304
+ ```
305
+
306
+ Assert exactly one saved booking afterwards. `{"surface":"staff","selector":"#close","forceClick":true}`
307
+ temporarily bypasses the disabled attribute, clicks, and restores it: assert the
308
+ server refusal and unchanged data. It is not a permission grant.
309
+ `{"surface":"phone","capabilities":{"webgl":false}}` installs a WebGL blocker
310
+ before reloading that surface, leaving ordinary controls functional. Set `true`
311
+ to restore WebGL on another reload. `{"surface":"phone","clock":1792800000000}`
312
+ sets the supported local frame clock.
313
+
314
+ `{"restart":true}` asks the authenticated local dev controller to restart its own
315
+ Wrangler child, keeps disk state and proof sessions, and waits for readiness.
316
+ It never kills a PID discovered by port. Start the current toolkit's dev server;
317
+ remote URLs and old servers without this controller refuse or time out. Put
318
+ restart/capability steps outside concurrent groups, then assert saved state.
319
+
320
+ ## Place time and upgrades
321
+
322
+ ```ts
323
+ import {placeTime} from '@homie-rocks/studio/links';
324
+ const startsAt = placeTime('2027-03-20', '09:30', 'America/Vancouver');
325
+ ```
326
+
327
+ Store the occurrence's local date/time and IANA zone in place config; derive epoch
328
+ milliseconds with the runtime's zone database. Never append `-07:00`. Invalid dates
329
+ and spring gaps are refused; repeated autumn times require explicit
330
+ `{disambiguation:'earlier'}` or `'later'`. Generate future occurrence dates from the
331
+ calendar rule, not a constant “next Saturday”. Update runtime zone data with the
332
+ runtime itself.
333
+
334
+ `upgrade --diff` includes recognized obsolete capability notes and standalone
335
+ 2–3-second named refresh timers over `createAppRecords` clients. `upgrade --apply`
336
+ replaces those timers with subscriptions and corrects exact stale notes without
337
+ marking old needs passed. Timer conversion uses the studio’s installed TypeScript
338
+ compiler; run `npm install` in the studio first if it is missing. Review the diff and rerun the affected needs. Custom
339
+ combined timer/jobs, hand-written routes and business-event mirrors need a semantic
340
+ migration to the APIs above; they are not automatically deleted. The owner's
341
+ words, historical evidence and unresolved integration work remain intact.
package/apps/apps.ts CHANGED
@@ -4,7 +4,9 @@ export interface AppRecord<T> { id: string; data: T; version: number; updatedAt:
4
4
  export function createAppRecords<T extends Record<string, unknown>>({ collection }: { collection: string }) {
5
5
  if (!/^[a-z][a-z0-9-]{0,39}$/.test(collection)) throw new Error('invalid collection');
6
6
  const pending = new Map<string, { resolve: (v: any) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout> }>();
7
+ const listeners = new Set<() => void>();
7
8
  const receive = (e: MessageEvent) => {
9
+ if(e.source === parent && e.data?.type === 'homie-app-changed' && e.data.collections?.includes(collection)) { for(const listener of listeners) listener(); return; }
8
10
  if (e.source !== parent || e.data?.type !== 'homie-app-result') return;
9
11
  const p = pending.get(e.data.id); if (!p) return;
10
12
  pending.delete(e.data.id); clearTimeout(p.timer);
@@ -18,11 +20,59 @@ export function createAppRecords<T extends Record<string, unknown>>({ collection
18
20
  parent.postMessage({ type: 'homie-app', id, collection, method, record, data, version }, '*');
19
21
  });
20
22
  return {
23
+ subscribe(listener: () => void) { listeners.add(listener); parent.postMessage({type:'homie-app-subscribe',collections:[collection]}, '*'); return () => {listeners.delete(listener);}; },
21
24
  async list(): Promise<AppRecord<T>[]> { return (await ask('GET')).records; },
22
25
  async get(id: string): Promise<AppRecord<T> | null> { return (await ask('GET', id)).records[0] ?? null; },
23
26
  create: (data: T, id = randomId()) => ask('POST', id, data),
24
27
  update: (record: AppRecord<T>, data: T) => ask('PUT', record.id, data, record.version),
25
28
  remove: (record: AppRecord<T>) => ask('DELETE', record.id, undefined, record.version),
26
- dispose() { removeEventListener('message', receive); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('disposed')); } pending.clear(); },
29
+ dispose() { listeners.clear(); removeEventListener('message', receive); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('disposed')); } pending.clear(); },
30
+ };
31
+ }
32
+
33
+ /** Call the same trusted studio definition used by MCP. The shell supplies identity. */
34
+ export function createAppTools() {
35
+ const pending = new Map<string, {resolve: (v: unknown) => void; reject: (e: Error) => void; timer: ReturnType<typeof setTimeout>}>();
36
+ const receive = (e: MessageEvent) => {
37
+ if (e.source !== parent || e.data?.type !== 'homie-app-result') return;
38
+ const p = pending.get(e.data.id); if (!p) return;
39
+ pending.delete(e.data.id); clearTimeout(p.timer);
40
+ if (e.data.result?.ok) p.resolve(e.data.result.data); else p.reject(new Error(e.data.result?.error ?? 'tool failed'));
41
+ };
42
+ addEventListener('message', receive);
43
+ return {
44
+ call<T = unknown>(name: string, input: Record<string, unknown> = {}): Promise<T> {
45
+ return new Promise((resolve, reject) => {
46
+ const id = randomId();
47
+ const timer = setTimeout(() => { pending.delete(id); reject(new Error('app shell did not answer')); }, 15000);
48
+ pending.set(id, {resolve: value => resolve(value as T), reject, timer});
49
+ parent.postMessage({type:'homie-app-tool', id, name, input}, '*');
50
+ });
51
+ },
52
+ dispose() { removeEventListener('message', receive); for (const p of pending.values()) { clearTimeout(p.timer); p.reject(new Error('disposed')); } pending.clear(); }
53
+ };
54
+ }
55
+
56
+ export interface AppContext { app: string; origin: string; url: string; presence: number; now: number | null }
57
+ /** Public self address and live presence supplied by the authenticated shell. */
58
+ export function createAppContext() {
59
+ let value: AppContext | null = null;
60
+ const listeners = new Set<(context: AppContext) => void>();
61
+ let resolve!: (context: AppContext) => void;
62
+ const ready = new Promise<AppContext>(r => {resolve = r;});
63
+ const receive = (e: MessageEvent) => {
64
+ if(e.source !== parent || e.data?.type !== 'homie-app-context') return;
65
+ value = Object.freeze(e.data.context) as AppContext;
66
+ resolve(value); for(const listener of listeners) listener(value);
67
+ };
68
+ addEventListener('message', receive);
69
+ parent.postMessage({type:'homie-app-context'}, '*');
70
+ parent.postMessage({type:'homie-app-subscribe',collections:[]}, '*');
71
+ return {
72
+ ready,
73
+ get current() {return value;},
74
+ now: () => value?.now ?? Date.now(),
75
+ subscribe(listener: (context: AppContext) => void) {listeners.add(listener); if(value)listener(value); return () => {listeners.delete(listener);};},
76
+ dispose() {removeEventListener('message',receive);listeners.clear();}
27
77
  };
28
78
  }
@@ -1,5 +1,11 @@
1
1
  # Building places: layout, authority and recovery
2
2
 
3
+ Start with [the six-build place recipe](../guides/app/PLACES.md) and
4
+ [the app contracts](../apps/APPS.md) for screen-to-tool calls, live records,
5
+ shared booking holds, named events, IANA time and adversarial needs. The small
6
+ CAS example below teaches authority; paid inventory uses `context.bookings` so
7
+ checkout and app capacity remain one system.
8
+
3
9
  ## TV join safe area
4
10
 
5
11
  On `/<id>/tv`, the shell sets `body.homie-join-visible` while the join card is present. It publishes CSS pixel values on the document root in both the shell and the sandboxed app: `--homie-join-x`, `--homie-join-y`, `--homie-join-width`, `--homie-join-height`, and `--homie-join-inset-top/right/bottom/left`. The insets include the card's distance from its configured screen edge. Inactive edges are zero; no card means all values are zero and the body class is absent. Measurements update on resize and iframe navigation. The card is at most 480 × 280 CSS pixels; its address wraps within that box.
@@ -383,3 +383,30 @@ A booking payment can be tested with:
383
383
  Create any records the handler reads first. First fire registers subscriptions
384
384
  before writing the event, so the first event is delivered too. No Stripe checkout
385
385
  or browser approval is needed for this explicitly synthetic local event.
386
+
387
+ ## Previous records and named business events
388
+
389
+ `record.changed` data includes `previous`: `null` for insert, the old data for
390
+ update/delete. `data` remains the new value for update and the deleted value for
391
+ delete. Both are captured by the same database transaction as the write. Import
392
+ `RecordChanged<T>` and `ScheduledEvent` from `@homie-rocks/studio/functions`;
393
+ scheduled time is `event.data.scheduledTime` (epoch milliseconds), not `event.at`.
394
+
395
+ Declare a feed in `studio.json` and its allowed payload:
396
+
397
+ ```json
398
+ {"mcp":{"events":{"arrivals":{"event":"place.arrived","audience":"owner","schema":{"type":"object","properties":{"booking":{"type":"string"}},"required":["booking"],"additionalProperties":false}}}}}
399
+ ```
400
+
401
+ A function declares `publish: ['arrivals']`, then calls
402
+ `await context.events.publish({feed:'arrivals', id:event.id, data:{booking:'...'}})`.
403
+ Use the source event ID, or a stable business request ID; repeats with the same
404
+ payload are no-ops, and changed payloads under the same ID are refused. Publication
405
+ and its deduplication receipt commit atomically. Receipts survive feed pruning.
406
+ Do not mirror every record or build a sleep-and-retry log. Keep contact details
407
+ out of operational events. Owner AIs use the existing feed cursor, MCP subscription
408
+ or declared webhook; functions never send email/SMS themselves.
409
+
410
+ Apply `0028_business_events.sql` on upgrade. Local proof publications carry their
411
+ scope and do not appear in production feed reads. Event feeds retain their declared
412
+ type; give different business feeds distinct types when their content differs.
@@ -8,6 +8,10 @@ export interface StudioFunction<Data = unknown> {
8
8
  /** Permit an explicit owner replay command to consume retained history. */
9
9
  replay?: boolean;
10
10
  events?: StudioTool['events'];
11
+ publish?: string[];
11
12
  handler(event: StudioEvent<Data>, context: ToolContext): unknown | Promise<unknown>;
12
13
  }
13
14
  export function defineFunction<Data = unknown>(definition: StudioFunction<Data>): StudioFunction<Data> { return definition; }
15
+
16
+ export interface RecordChanged<T = Record<string, unknown>> { app: string; collection: string; record: string; version: number; operation: "insert" | "update" | "delete"; data: T; previous: T | null }
17
+ export interface ScheduledEvent { function: string; cron: string; scheduledTime: number }
@@ -0,0 +1,123 @@
1
+ # Build a place people recognize
2
+
3
+ Use this with APPS.md and TOOLS.md. Six place builds exposed the same rule:
4
+ make the owner's dream visible, with one trusted operation behind every action.
5
+
6
+ 1. **Start with their paragraph.** Quote the owner's actual words in CODEX.md,
7
+ including sensory details and operating rules. Write one executable need per
8
+ clause. Describe Scene as data: “held = white ball; confirmed = gold; mine =
9
+ gold ring; empty capacity = empty pegs.” List phone, wall, signed-in staff,
10
+ customer AI and owner AI with their addresses and actions. Record what is
11
+ simulated, evidence viewports and who reviewed it. Local review is not independent.
12
+ 2. **Make records visible.** Give each meaningful record an object, each state a
13
+ material, and the visitor's object a clear marker. Arrivals pop and chime;
14
+ outcomes become bags, cups or flags. Empty capacity can be negative space.
15
+ 3. **Spend geometry where it reads.** Use one layout curve for paths, trees and
16
+ objects. Add a gradient sky shader and sun, fog matching the horizon, terrain
17
+ vertex colours by zone, and seeded low-poly local vegetation. Animate water
18
+ and a flag in vertex shaders; avoid recomputing a large mesh's normals every
19
+ frame. Ease role-specific cameras with `1 - Math.exp(-dt * 4)`. Cap pixel
20
+ ratio on phones, pause hidden frames, respect reduced motion. Generate a
21
+ filtered noise ambient bed after the first sound opt-in; its gain follows the
22
+ scene. Compose a share card from the live scene and actual outcome.
23
+ 4. **One set of rules.** Put validation and transitions in `domain.ts`; the view
24
+ may preview them, but a `defineTool` handler decides and saves. Screens call
25
+ `createAppTools().call`, AIs call the same definition over MCP. Use strict
26
+ schemas, bounded inputs, versioned writes with revalidation on conflict, and
27
+ a caller-generated request ID. A repeated ID with different data is refused.
28
+ Do not rebuild the operation with browser record writes or a custom route.
29
+ 5. **Plan the AI's whole visit.** Provide `place_info` (“Call this first”: rules,
30
+ availability, prices, notice), `place_book` (all choices in one call, bounded
31
+ enums from config, a private management credential and a `next` sentence),
32
+ and `place_booking` (owner-authorized lookup/change/cancel). Never expose
33
+ management credentials in public records. Owner tools read an outbox, mark
34
+ messages sent, consume event feeds, and repeat next month by a calendar rule.
35
+ Homie composes; the owner's AI sends through its own connector. A repeat
36
+ invites past visitors; it never silently enrols them.
37
+ 6. **Operate from one config.** Put the place's IANA zone, schedule rule, capacity,
38
+ prices, add-ons and notice in one config. Derive UI, tools and `shop.json` from
39
+ it during build. Do not maintain a second price list. Model holds → confirm →
40
+ scheduled release, with fixed spot IDs and version checks on reuse. Group
41
+ allocation must be atomic, never a loop that reports partial success. Use the
42
+ toolkit's booking capacity for paid inventory. HANDOVER.md says what runs
43
+ where, the monthly rhythm, how to connect an AI, what to do when the owner
44
+ leaves, and which integrations are still unproven.
45
+
46
+ ## Small working boundaries
47
+
48
+ The examples use the installed toolkit's exports. Declare `reserve` in `tools/`;
49
+ this small example reserves one whole slot for a signed-in account. Keep the
50
+ public collection a projection without account IDs or secrets. A production
51
+ paid slot uses the shop booking system, not a second ledger.
52
+
53
+ ```ts
54
+ import { defineTool } from '@homie-rocks/studio/tools';
55
+ export default defineTool<{ slot: string; request: string }>({
56
+ name: 'reserve', description: 'Reserve one slot; repeat the same request safely.',
57
+ audience: 'signed-in',
58
+ inputSchema: {
59
+ type: 'object', additionalProperties: false, required: ['slot', 'request'],
60
+ properties: {
61
+ slot: { type: 'string', enum: ['morning', 'afternoon'] },
62
+ request: { type: 'string', pattern: '^[a-f0-9]{32}$' }
63
+ }
64
+ },
65
+ async handler(input, ctx) {
66
+ const owner = ctx.caller.id;
67
+ const key = 'slot:' + input.slot;
68
+ const old = await ctx.database.getVersioned(key);
69
+ if (old) {
70
+ const saved = old.value as { owner: string; request: string };
71
+ if (saved.owner === owner && saved.request === input.request) return { booked: true };
72
+ return { booked: false, next: 'That slot is full. No place has been taken.' };
73
+ }
74
+ const result = await ctx.database.put(key, { owner, request: input.request }, { version: 0 });
75
+ return { booked: result.ok, next: result.ok ? 'Your place is reserved.' : 'That slot filled. Choose another.' };
76
+ }
77
+ });
78
+ ```
79
+
80
+ ```ts
81
+ import { createAppTools, createAppContext, createAppRecords } from '@homie-rocks/studio/apps';
82
+ const tools = createAppTools();
83
+ const context = createAppContext();
84
+ const self = await context.ready; // self.url is public; it contains no private access link
85
+ const records = createAppRecords<{ status: string }>({ collection: 'spots' });
86
+ const refresh = async () => {
87
+ const rows = await records.list();
88
+ document.querySelector('output')!.textContent = `${rows.length} places · ${self.url}`;
89
+ };
90
+ const stop = records.subscribe(() => { void refresh(); });
91
+ await refresh();
92
+ // In the visitor's click handler, preserve request across network retries:
93
+ const request = crypto.randomUUID().replaceAll('-', '');
94
+ await tools.call('reserve', { slot: 'morning', request });
95
+ // On unmount: stop(); records.dispose(); context.dispose(); tools.dispose();
96
+ ```
97
+
98
+ Build the controls first: an area menu is the primary navigation, and tapping a
99
+ 3D object selects the same area. Catch renderer creation failure and context loss;
100
+ keep every booking/control usable without WebGL. Refusals are human sentences
101
+ that say what happened. Prove refusals by bypassing disabled controls, race two
102
+ browsers for the last seat, reload and restart, and run with WebGL unavailable.
103
+ Document the exact commands and results; an empty assertion list is not proof.
104
+
105
+ ## Do not teach the mistakes
106
+
107
+ - No booking codes in public collections or anonymous edits of arbitrary bookings.
108
+ Bind tickets to their account/seat, or verify a secret only the booker holds.
109
+ - No public staff console. Use a signed-in role and check it on the server.
110
+ Label an intentionally public simulator in both UI and Codex.
111
+ - Never interpolate visitor text into `innerHTML`: a stored XSS reached a staff
112
+ tablet in the study. Use `textContent` for names, contacts, notices and outbox text.
113
+ - Never issue an “official receipt” for an unpaid donation. Record a pledge as a
114
+ pledge; count a paid gift only after a verified, deduplicated `order.paid` event,
115
+ with the gift's ID as its reference.
116
+ - No fixed UTC offsets or one hard-coded event date. Use IANA zones and recurring
117
+ calendar rules; carry the same occurrence into site copy and all screens.
118
+ - No sleep-and-retry event arrays or staff-device cron jobs. Use durable functions,
119
+ version checks, declared feeds and the server scheduler.
120
+ - No live-only assets. Commit every asset the site serves, with its provenance,
121
+ and verify a clean checkout can build it. Declare only packages actually used.
122
+ - No invented reviews, impact figures or verification. Label estimates and test
123
+ payments honestly; remove stale workaround claims when upgrading.
@@ -6,6 +6,9 @@ compatibility: Node 22 and Chrome; the studio's pinned toolkit and Wrangler.
6
6
 
7
7
  # Make an app
8
8
 
9
+ For place-based builds, read [the six-build patterns](../app/PLACES.md): owner words to needs, records as objects, a local scene and ambience, one trusted operation for screens and AI, graceful graphics, and adversarial proof. Apply the same authority and honest-evidence rules to game economies and tickets.
10
+
11
+
9
12
  For the installed version’s payment, records and local-check contracts, start with
10
13
  `node_modules/@homie-rocks/studio/apps/APPS.md`. Its “Real Stripe test checkout on
11
14
  this computer” section is the supported offline-hosting path: real Stripe test
@@ -25,6 +25,9 @@ cover, doors and platforms, use declared entity `collider` state and movement's
25
25
 
26
26
  # Make a game
27
27
 
28
+ For place-based builds, read [the six-build patterns](../app/PLACES.md): owner words to needs, records as objects, a local scene and ambience, one trusted operation for screens and AI, graceful graphics, and adversarial proof. Apply the same authority and honest-evidence rules to game economies and tickets.
29
+
30
+
28
31
  You are in a Homie studio when the folder (or one above it) has `studio.json`. If there
29
32
  is none, use the `studio-setup` skill first.
30
33
 
@@ -49,27 +49,34 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
49
49
  3. Append `{ "method": "POST", "path": "…", "params": {…}, "body": <Stripe response> }`
50
50
  for **writes as well as reads**. Keep this setup's observations across batches.
51
51
  Product creation includes its default Price; link creation expands line items.
52
- Product and Price Search use studio/origin metadata. Existing links are fetched by
53
- IDs from the verified configuration; Stripe has no Payment Link metadata search.
54
- Keep `.studio/stripe-mcp.json` when moving the studio to another computer. Without
55
- it, repeat creates use their idempotency keys; outside Stripe's idempotency retention
56
- window, recover the studio's existing link IDs before setup to avoid duplicates.
57
- Follow search `next_page` cursors. Search is eventually consistent: write responses
58
- are the immediate evidence. On an uncertain write, retry its exact idempotency key;
59
- never discard successful responses or invent IDs. Start a new read set when checking
60
- later external edits, changing accounts, or changing mode.
52
+ Product and Price Search use studio/origin metadata. Payment Links use paginated
53
+ list reads with `expand[]=data.line_items`, then filter studio/origin metadata locally;
54
+ Stripe has no Payment Link metadata search. There is no per-link read loop.
55
+ The planner saves public observations in `.studio/stripe-mcp-observations.json` and
56
+ reuses them on the next call; send only new responses. Keep that file and
57
+ `.studio/stripe-mcp.json` when moving computers. Changed shop/slot settings refresh
58
+ mutable links while retaining product/price discovery. Account, mode and origin
59
+ changes start separate reads. Pass `refresh: true` for Dashboard changes or a fresh
60
+ verification before webhook installation (CLI: include it in `--have` input).
61
+ Follow search `next_page` and list `starting_after` cursors. On an uncertain write,
62
+ retry its exact idempotency key; never discard successful responses or invent IDs.
61
63
  4. Call `stripe_shop_plan` with `verify: true` using those observations (CLI
62
64
  `homie-studio shop verify --have plan-input.json`). Verification checks real fields,
63
65
  line items, menus, slot limits and portal settings. It saves compact public Worker
64
66
  configuration and provenance to `.studio/stripe-mcp.json`, never API response history.
65
- Deploy installs those links. Checkout then reports only `webhook-secret` missing;
67
+ Build writes public links to `site/dist/_site/stripe-shop-links.json`; deploy ships
68
+ that asset, with only its path in Wrangler vars. Large catalogues never use a text
69
+ binding. Deploy after verifying, including when setup began on an already-live site. Checkout then reports only `webhook-secret` missing;
66
70
  it cannot open until signed payment events can confirm orders. Changing shop.json,
67
71
  the slot manifest or origin requires verification again. Local test configuration
68
72
  goes to `.dev.vars`.
69
73
  5. For a deployed HTTPS studio, run `stripe_webhook` (CLI `homie-studio shop webhook`).
70
74
  Follow `studio_job`. This uses Stripe CLI only for private webhook installation,
71
- checks the same account as the MCP, and installs the signing secret and verified
72
- links through Wrangler stdin. The CLI needs one separate browser approval if its
75
+ checks the same account as the MCP, and installs only the signing secret through
76
+ Wrangler stdin. If Stripe CLI is missing, the tool installs the official
77
+ `@stripe/cli@latest` npm package automatically, checks the executable, then continues.
78
+ An installation failure reports npm permissions/PATH and a retry command. The CLI
79
+ needs one separate browser approval if its
73
80
  studio profile is not already connected. MCP approval cannot be reused or exported.
74
81
  Cloudflare authorization and an existing deployment are prerequisites.
75
82
  6. After installation, perform the returned `retire` operations through Stripe MCP to
@@ -80,6 +87,14 @@ calling it; tool argument shapes can change. See https://docs.stripe.com/mcp.
80
87
  endpoint's delivery result and the app's verified records. Do not type a card into
81
88
  a page for an automated proof; use Stripe's documented test PaymentMethods/fixtures.
82
89
 
90
+ Upgrades accept 0.51.6 verifications without a source fingerprint only when their
91
+ saved content fingerprint still matches the current shop, slots, site and mode.
92
+ Actual changes name the affected settings and require verification. An unfinished
93
+ setup still builds and deploys with checkout closed and one next step: finish
94
+ `stripe_shop_plan` with `verify: true`. Missing/unreadable assets also close checkout;
95
+ old link secrets cannot reopen it. Cached observations never renew the verification
96
+ time; use fresh reads if the private webhook tool requests them.
97
+
83
98
  Never create a webhook through model-visible Stripe MCP: its response exposes the signing secret.
84
99
 
85
100
  **Why the separate approval (checked 2026-10-14).** Stripe's
package/lib/app-needs.mjs CHANGED
@@ -23,7 +23,7 @@ export function readAppNeeds(root, id) {
23
23
  }
24
24
 
25
25
  /** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
26
- export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
26
+ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless, restart }) {
27
27
  const results = [];
28
28
  const appBarrier=ready;
29
29
  ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
@@ -31,7 +31,7 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
31
31
  const surface = name => { const s = surfaces.find(s => s.name === name && (!activeRole || s.role === activeRole)) ?? surfaces.find(s => s.name === name); if (!s) throw new Error(`unknown surface ${name}`); return s; };
32
32
  for (const need of plan.needs) {
33
33
  activeRole = need.role;
34
- const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
34
+ const names = [...new Set([...needSteps(need.steps), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
35
35
  const saved = new Map();
36
36
  const run = async item => { if (!headless) throw new Error('headless MCP proof requires local dev and its test credential'); return headless(item,item.role ?? need.role); };
37
37
  let waiting = 'validating assertions';
@@ -41,19 +41,34 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
41
41
  const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
42
42
  for (const name of names) { if(popupNames.has(name))continue; const s = surface(name); if (need.role && s.role !== need.role) throw new Error(`${name} requires role ${need.role}, got ${s.role ?? 'unspecified'}`); }
43
43
  for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
44
- for (const rawStep of need.steps ?? []) {
44
+ const perform = async rawStep => {
45
45
  const step=resolveNeedResults(rawStep,saved);
46
+ if(step.concurrent){
47
+ if(!Array.isArray(step.concurrent)||step.concurrent.length<2)throw new Error('concurrent needs at least two steps');
48
+ if(needSteps(step.concurrent).some(s=>s.restart||s.capabilities))throw new Error('Restart and capabilities changes must be outside a race');
49
+ const results=await Promise.allSettled(step.concurrent.map(perform));
50
+ const failed=results.find(r=>r.status==='rejected');if(failed)throw failed.reason;return;
51
+ }
52
+ if(step.restart){if(!restart)throw new Error('Restart requires the local dev controller');await restart();for(const s of surfaces)await ready(s);return;}
53
+
46
54
  if (step.mcp || step.schedule) {
47
55
  waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
48
56
  const value = await run(step);
49
57
  if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
50
- continue;
58
+ return;
51
59
  }
52
60
  const target=surface(step.surface);
53
61
  if(!step.reload)await ready(target);
54
62
  const { page } = target;
63
+ if(step.capabilities){
64
+ if(typeof step.capabilities.webgl!=='boolean')throw new Error('capabilities.webgl must be boolean');
65
+ await page.evaluateOnNewDocument(configureWebGL,step.capabilities.webgl);
66
+ await page.reload({waitUntil:'domcontentloaded'});await ready(target);return;
67
+ }
68
+ if(Object.hasOwn(step,'clock')){await page.evaluate(value=>window.homieApp.setTestClock(value),step.clock);return;}
69
+
55
70
  waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
56
- if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
71
+ if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); return; }
57
72
  const act = fn => actInApp(target,step.selector,fn,{ready});
58
73
  if (step.click && step.popup) {
59
74
  let listener,timer;
@@ -67,11 +82,15 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
67
82
  await page.bringToFront();
68
83
  } finally {clearTimeout(timer);page.off('popup',listener);}
69
84
  }
85
+ else if(step.forceClick){await act(frame=>frame.$eval(step.selector,el=>{
86
+ const disabled=el.disabled;try{el.disabled=false;el.click();}finally{el.disabled=disabled;}
87
+ }));}
70
88
  else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
71
89
  else if (step.fill !== undefined) { await act(async frame=>{ await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }); }
72
90
  else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
73
91
  else throw new Error('step needs click, fill, select or reload');
74
- }
92
+ };
93
+ for(const step of need.steps??[])await perform(step);
75
94
  for (const rawAssertion of need.assertions) {
76
95
  const assertion=resolveNeedResults(rawAssertion,saved);
77
96
  if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
@@ -170,3 +189,19 @@ export function resolveNeedResults(value,saved) {
170
189
  }
171
190
  return value;
172
191
  }
192
+
193
+ /** Flatten only action groups; used for browser/role discovery before a race starts. */
194
+ export function needSteps(steps = []) { return steps.flatMap(s => s.concurrent ? needSteps(s.concurrent) : [s]); }
195
+ export function configureWebGL(enabled) {
196
+ window.__homieWebGLDisabled = !enabled;
197
+ if(window.__homieWebGLWrapped)return;
198
+ window.__homieWebGLWrapped = true;
199
+ for(const proto of [globalThis.HTMLCanvasElement?.prototype,globalThis.OffscreenCanvas?.prototype]) {
200
+ if(!proto)continue;
201
+ const original = proto.getContext;
202
+ proto.getContext = function(kind, ...args) {
203
+ if(window.__homieWebGLDisabled && /^(webgl2?|experimental-webgl)$/.test(kind))return null;
204
+ return Reflect.apply(original,this,[kind,...args]);
205
+ };
206
+ }
207
+ }
package/lib/build.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import {verifiedMcpConfig,publicMcpSetup} from './shop-mcp.mjs';
1
2
  import { directoryUrl } from '../worker/directory-choice.mjs';
2
3
  import { domainOrigin } from './studio.mjs';
3
4
  import { appProblems } from '../worker/app-format.mjs';
@@ -686,6 +687,10 @@ async function buildInto(dist, { esbuild, studio, shop, live, games, before, typ
686
687
  // GAME PARTS: every packed version of every SHARED part into dist/parts/, and its index; a private part is never copied.
687
688
  const partsBuilt = buildParts(root, dist, { studio, log });
688
689
  mkdirSync(join(dist, '_site'), { recursive: true });
690
+ if(publicMcpSetup(root)) {
691
+ const verified=verifiedMcpConfig(root);
692
+ writeFileSync(join(dist,'_site','stripe-shop-links.json'),JSON.stringify({config:verified?.result.config??null,next:verified?null:'Ask your AI to finish stripe_shop_plan with verify:true.'}));
693
+ }
689
694
  writeFileSync(join(dist, '_site', 'posts.json'), `${JSON.stringify({ v: 1, posts })}\n`);
690
695
  const studioExtras = schemaExtras(s.schema, { where: 'studio.json site', types: 'Organization', log });
691
696
  // The Lounge (0.29.0): studio.json "lounge", checked; off when a game already has /lounge/.