@homie-rocks/studio 0.51.5 → 0.51.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +20 -0
- package/apps/APPS.md +15 -0
- package/guides/app/NEEDS.md +160 -0
- package/guides/app/SKILL.md +19 -3
- package/lib/app-needs.mjs +86 -18
- package/lib/app-ready.mjs +21 -0
- package/lib/check.mjs +16 -7
- package/lib/codex.mjs +7 -5
- package/lib/needs-headless.mjs +34 -0
- package/lib/shop-links.mjs +27 -1
- package/lib/template-history.json +42 -0
- package/package.json +1 -1
- package/shop/SHOP.md +44 -0
- package/worker/app-records.mjs +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +9 -3
- package/worker/local-test.mjs +4 -0
- package/worker/mcp-events.mjs +2 -2
- package/worker/mcp-tools.mjs +19 -1
- package/worker/mcp.mjs +1 -1
- package/worker/shop-links.mjs +1 -1
- package/worker/shop-rules.mjs +3 -0
- package/worker/shop.mjs +51 -3
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,26 @@ 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.51.6] - 2026-10-14
|
|
21
|
+
|
|
22
|
+
**Plugin 0.51.6**
|
|
23
|
+
|
|
24
|
+
Keep each customer's exact basket through payment, and check that your studio meets its needs across screens, AI jobs and handover.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **Check the whole service:** Owner needs can call studio MCP tools as customer, staff or owner, assert returned results and saved records, invoke declared scheduled functions locally, read event feeds and MCP Events, check webhook delivery, and verify handover files. Checks keep role permissions and test-record isolation. Headless-only plans run without Chrome; missing tools, permissions, effects and text explain why a need is not met.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **Exact rounds and baskets:** Opt-in keyless saved baskets keep the phone's exact items and quantities. One custom-amount Payment Link carries the saved total; signed payments are checked against the saved basket and report full payment or a shortfall for staff. Short payments grant no automatic entitlements. The Worker needs no Stripe API key, and existing adjustable Stripe menus keep their behavior.
|
|
33
|
+
- **Clear acceptance and evidence:** Passing assertions mark a need met even when captures are absent or fail. Capture diagnostics identify the surface, explain the failure and show how to collect evidence with `--shots`.
|
|
34
|
+
- **Reliable changing controls:** Click, fill and select actions retry detached elements within a bounded wait. The app skill, packaged guides and Codex template explain how updating controls in place preserves a held press across delayed refreshes.
|
|
35
|
+
|
|
36
|
+
### Upgrade notes
|
|
37
|
+
|
|
38
|
+
- Saved baskets are opt-in through `shop.json.basket` and require Stripe with tax-inclusive menu prices. Automatic tax and Managed Payments are refused for basket mode. Staff can use the reported basket total, actual payment and settlement status to collect a shortfall.
|
|
39
|
+
|
|
20
40
|
## [0.51.5] - 2026-10-14
|
|
21
41
|
|
|
22
42
|
**Plugin 0.51.5**
|
package/apps/APPS.md
CHANGED
|
@@ -181,3 +181,18 @@ The phone remains its own surface; assert its confirmed gift after checkout. The
|
|
|
181
181
|
checker brings each page forward before reading or capturing it. Use the real
|
|
182
182
|
provider's controls, never a test-only app action. App sharing defaults to the top
|
|
183
183
|
right, clear of bottom tabs; `screen.share` remains an author-controlled override.
|
|
184
|
+
|
|
185
|
+
## Every owner need is executable
|
|
186
|
+
|
|
187
|
+
The Codex `needs` block supports existing UI steps plus named-role MCP calls,
|
|
188
|
+
returned-result assertions, simulated schedule ticks, record/results reads,
|
|
189
|
+
event feed/MCP Events/webhook checks and handover file contents. See the packaged
|
|
190
|
+
[needs format and examples](../guides/app/NEEDS.md). Passing assertions are **met**;
|
|
191
|
+
captures are additional evidence. Missing captures name their surface, reason and
|
|
192
|
+
how to rerun with `--shots .checks`. Detached action elements are re-queried for a
|
|
193
|
+
bounded ten seconds; ambiguous post-dispatch failures are not replayed.
|
|
194
|
+
|
|
195
|
+
Keep buttons connected during delayed refreshes: create the button once and set
|
|
196
|
+
`button.textContent = label` when data arrives. Never replace a held button with
|
|
197
|
+
`innerHTML` or `replaceWith`; that can cancel navigation between pointer down and
|
|
198
|
+
up. The needs guide has a short complete example and explains outcome assertions.
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# Check every owner need
|
|
2
|
+
|
|
3
|
+
The JSON array in the app's `CODEX.md` fenced `needs` block is the acceptance
|
|
4
|
+
source. Keep every owner statement, including AI jobs, reminders, results and
|
|
5
|
+
handover. Each need has a unique `id`, `statement`, optional `role` and `steps`,
|
|
6
|
+
and nonempty `assertions`. An unavailable capability stays as a need with an
|
|
7
|
+
empty assertion list and a prose explanation; check reports it not met.
|
|
8
|
+
|
|
9
|
+
Run `homie-studio check <app> --url http://localhost:8787 --shots .checks` with
|
|
10
|
+
`homie-studio dev` running in the same studio. Headless-only needs require no
|
|
11
|
+
Chrome. UI steps and headless steps can be combined in one need and run in order.
|
|
12
|
+
Each need starts with its own saved results; records are shared across needs in
|
|
13
|
+
the run. The default isolated record scope is shared by browsers, MCP calls and
|
|
14
|
+
scheduled effects. `--no-isolate` explicitly uses existing records. Isolation is
|
|
15
|
+
for app records, not a sandbox for external email/SMS/payment calls: use the
|
|
16
|
+
service's test connection for those jobs.
|
|
17
|
+
|
|
18
|
+
## Passing assertions and capture evidence
|
|
19
|
+
|
|
20
|
+
Passing assertions mean **met**, with or without screenshots. A missing capture
|
|
21
|
+
never changes that verdict. For every UI surface without a capture, the result
|
|
22
|
+
names the surface, why it was unavailable (including the browser's error), and
|
|
23
|
+
tells you to rerun with `--shots .checks`. Fix the reported disk/browser error
|
|
24
|
+
first. Successful captures are listed in the saved needs report. Inspect them
|
|
25
|
+
against the owner's scene: automation proves the assertions, not visual quality.
|
|
26
|
+
Headless needs have no screenshots to collect.
|
|
27
|
+
|
|
28
|
+
Click, fill and select actions re-query their selector for up to ten seconds when
|
|
29
|
+
the browser reports a detached/stale element. A permanently unstable control
|
|
30
|
+
reports its surface and selector. Other action failures are not replayed: a
|
|
31
|
+
navigation error after dispatch does not prove that a purchase did not happen.
|
|
32
|
+
|
|
33
|
+
## Customer and staff AI calls and returned results
|
|
34
|
+
|
|
35
|
+
Headless actions require an explicit `role`, inherited from the need or overridden
|
|
36
|
+
on a headless step/assertion. Use an app's declared role, or `owner` for owner-only
|
|
37
|
+
operations. The dev-only signed identity enforces real MCP audiences and record
|
|
38
|
+
capabilities; a customer is not silently promoted to owner. Production OAuth
|
|
39
|
+
approval is unchanged. Missing dev credentials, tools or role permissions produce
|
|
40
|
+
plain not-met reasons.
|
|
41
|
+
|
|
42
|
+
```needs
|
|
43
|
+
[
|
|
44
|
+
{"id":"ai-book","statement":"A customer's AI books in one call","role":"customer",
|
|
45
|
+
"steps":[{"mcp":{"tool":"book","arguments":{"name":"Ada"}},"save":"booking"}],
|
|
46
|
+
"assertions":[{"result":"booking","contains":{"ok":true}}]},
|
|
47
|
+
{"id":"open-roles","statement":"Staff AI can ask which roles are still open","role":"staff",
|
|
48
|
+
"steps":[{"mcp":{"tool":"open_roles"},"save":"open"}],
|
|
49
|
+
"assertions":[{"result":"open","path":"roles","contains":["cook"]}]}
|
|
50
|
+
]
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Use actual tool names, arguments and returned fields from this studio. `save`
|
|
54
|
+
names the returned result; duplicate names fail. `result` reads it. `path` selects
|
|
55
|
+
dot-separated object keys or array indexes, such as `records.0.data.name`.
|
|
56
|
+
`equals` compares the full selected JSON value; `contains` matches a subset of
|
|
57
|
+
object fields, each expected array entry against an actual entry, or a substring.
|
|
58
|
+
`text` checks a string for one substring or every string in an array. Use `{"$result":"booking.id"}` anywhere in a subsequent step or assertion
|
|
59
|
+
to refer to a saved value, without string interpolation. A missing
|
|
60
|
+
result/path, wrong value or malformed assertion stays not met, naming the failed
|
|
61
|
+
condition. Standard studio tool envelopes are unwrapped to their `data` value;
|
|
62
|
+
other tools retain their structured content or MCP result.
|
|
63
|
+
|
|
64
|
+
## Schedules, records and results
|
|
65
|
+
|
|
66
|
+
A `schedule` step invokes a named declared scheduled function through the local
|
|
67
|
+
MCP dispatcher, with a simulated timestamp in epoch milliseconds. It runs the
|
|
68
|
+
handler headless and reports handler failures. This is a simulated tick, not
|
|
69
|
+
proof that a deployed cron fired or an external message reached a person. Assert
|
|
70
|
+
the actual resulting record or queue entry; do not just assert the trigger's ID.
|
|
71
|
+
The handler still needs a declared role with the relevant record capabilities.
|
|
72
|
+
|
|
73
|
+
```needs
|
|
74
|
+
[
|
|
75
|
+
{"id":"remind","statement":"Daily reminders queue for booked guests","role":"owner",
|
|
76
|
+
"steps":[{"schedule":{"name":"reminders","at":1800000000000}}],
|
|
77
|
+
"assertions":[{"role":"staff","records":{"collection":"reminders"},
|
|
78
|
+
"contains":{"records":[{"data":{"name":"Ada","state":"queued"}}]}}]},
|
|
79
|
+
{"id":"results","statement":"Staff can read the completed result","role":"staff",
|
|
80
|
+
"assertions":[{"records":{"collection":"results","id":"one"},
|
|
81
|
+
"contains":{"data":{"status":"complete"}}}]}
|
|
82
|
+
]
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
`records` uses the real `app_records` read tool, bound to this app, the named role
|
|
86
|
+
and this check's isolation scope. Supply collection and optional ID as accepted by that tool. Use its actual response shape (a single record versus
|
|
87
|
+
a list). Create fixtures with steps before asserting them; fresh runs start empty.
|
|
88
|
+
|
|
89
|
+
## Owner AI events and webhook handoff
|
|
90
|
+
|
|
91
|
+
Call `studio_events_read` to prove the feed contains the event, or use MCP Events
|
|
92
|
+
`events/poll` with an explicit cursor to prove event retrieval. `events/list`
|
|
93
|
+
checks discovery. `resources/read` also works and preserves its MCP `contents`
|
|
94
|
+
array. These prove the studio exposes an event to the named role; they do not
|
|
95
|
+
claim an external AI held a live connection or acted on it.
|
|
96
|
+
|
|
97
|
+
```needs
|
|
98
|
+
[
|
|
99
|
+
{"id":"owner-feed","statement":"The owner's AI can read new bookings","role":"owner",
|
|
100
|
+
"steps":[{"mcp":{"tool":"book","arguments":{"name":"Ada"}},"role":"customer"}],
|
|
101
|
+
"assertions":[{"mcp":{"tool":"studio_events_read","arguments":{"feed":"bookings"}},
|
|
102
|
+
"contains":{"events":[{"type":"record.changed","data":{"data":{"name":"Ada"}}}]}}]},
|
|
103
|
+
{"id":"mcp-events","statement":"Owner AI retrieves booking events through MCP Events","role":"owner",
|
|
104
|
+
"assertions":[{"mcp":{"method":"events/poll","params":{"name":"bookings","cursor":"v1:bookings:0"}},
|
|
105
|
+
"contains":{"events":[{"name":"bookings"}]}}]},
|
|
106
|
+
{"id":"webhook","statement":"The declared owner webhook accepts a signed test event","role":"owner",
|
|
107
|
+
"steps":[{"mcp":{"tool":"studio_events_webhook_test","arguments":{"feed":"bookings"}},"save":"delivery"}],
|
|
108
|
+
"assertions":[{"mcp":{"tool":"studio_events_webhook_status","arguments":{"feed":"bookings"}},
|
|
109
|
+
"waitMs":5000,"contains":{"deliveries":[{"event":{"$result":"delivery.id"},"state":"complete"}]}}]}
|
|
110
|
+
]
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Declare the feed/audience and webhook first using the existing functions guide.
|
|
114
|
+
The webhook test sends an explicitly synthetic event to the declared receiver;
|
|
115
|
+
match the saved delivery ID to avoid mistaking an older delivery for this test.
|
|
116
|
+
Use `studio_events_webhook_status` for subsequent attempts and errors.
|
|
117
|
+
A status assertion proves the receiver's acknowledgement, not its downstream
|
|
118
|
+
email/SMS or AI work. Read-only MCP/records assertions make one call by default. For asynchronous
|
|
119
|
+
effects, set `waitMs` (0–10000) to re-read until the expected value appears.
|
|
120
|
+
Tool/permission errors fail immediately. Saved results are immutable and are
|
|
121
|
+
never retried. Never put a mutating tool in an assertion; use a step once and
|
|
122
|
+
assert its saved result or durable effect. Unknown methods/tools and denied feeds
|
|
123
|
+
fail explicitly. Secrets do not belong in the needs block.
|
|
124
|
+
|
|
125
|
+
## Handover files
|
|
126
|
+
|
|
127
|
+
```needs
|
|
128
|
+
[
|
|
129
|
+
{"id":"handover","statement":"The owner has AI connection and reminder instructions",
|
|
130
|
+
"assertions":[{"file":"HANDOVER.md","text":["/mcp","Reminders run daily"]}]}
|
|
131
|
+
]
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
`file` is relative to the studio root. The file must exist and be readable; `text`
|
|
135
|
+
checks all requested contents. Without `text` it proves only existence/readability.
|
|
136
|
+
Use this for handover instructions and exported text results; use `records` for
|
|
137
|
+
live structured results. Missing files and missing text name the exact failure.
|
|
138
|
+
|
|
139
|
+
## Keep controls connected during delayed refreshes
|
|
140
|
+
|
|
141
|
+
Never replace a button under a press. A delayed fetch can finish between pointer
|
|
142
|
+
down and pointer up; replacing the node cancels its click/navigation and loses
|
|
143
|
+
focus. Create interactive nodes once, key repeated items by stable record IDs,
|
|
144
|
+
and update text/attributes in place. Keep pending/disabled state deliberate.
|
|
145
|
+
|
|
146
|
+
```js
|
|
147
|
+
const choose = document.createElement('button');
|
|
148
|
+
choose.textContent = 'Choose crew';
|
|
149
|
+
choose.addEventListener('click', () => selectRole('crew'));
|
|
150
|
+
controls.append(choose);
|
|
151
|
+
async function refresh() {
|
|
152
|
+
const { open } = await loadRoles();
|
|
153
|
+
choose.textContent = `Choose crew (${open} places)`;
|
|
154
|
+
// Keep this same button connected; do not replace controls.innerHTML here.
|
|
155
|
+
}
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
The runner can retry a detected stale handle before dispatch; it cannot infer
|
|
159
|
+
that an app silently cancelled a press after dispatch. Both stable DOM updates
|
|
160
|
+
and an assertion on the navigation's outcome are necessary.
|
package/guides/app/SKILL.md
CHANGED
|
@@ -100,7 +100,7 @@ The Codex's `needs` fenced block is a JSON array, one entry per need from the re
|
|
|
100
100
|
|
|
101
101
|
Steps support `fill`, `select`, `click: true` and `reload: true`; surfaces are `wall`, `phone`, `phone-two`, `tablet`, `kiosk` and `staff` (a tablet viewport). Set a need’s `role` to the declared role to prove. Each assertion names its surface; only the named screens are required. Assertions use a visible selector with optional contained text, or an observed effect. An honestly unavailable capability remains in the block with no assertions and a plain explanation in the prose; check reports it not met. Do not delete, weaken or replace a need to get green. Static needs such as accurate prices may have no action steps. Use semantic attributes on real visible scene elements for testability, never hidden QA-only counters.
|
|
102
102
|
|
|
103
|
-
Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not, the assertion result and captures of
|
|
103
|
+
Run build and the shared-action check, then the needs step runs on those real browsers. Save captures with `--shots .checks`. The result contains each statement, met/partly/not, the assertion result and available captures of named surfaces; `.studio/checks/<id>-needs.json` drives the Codex's Needs status. Redraw `codex <id>` after checking. Passing a selector assertion is evidence, not a visual judgement: read every capture and perform the full flows as owner and customer using the playtest skill. If the picture fails the words, it is not done even with green assertions.
|
|
104
104
|
|
|
105
105
|
For staff flows, sign in through the existing local owner link and prove the transition in real Chrome. Keep evidence in the Codex alongside the automated flows. Confirm unauthorized writes are refused and records remain after all screens close. Never call an untested privileged path passed. Before final handover, show the need table with evidence and explicit remaining gaps. If the starter or a board of boxes is still visible, return to the scene design and build it.
|
|
106
106
|
|
|
@@ -252,8 +252,8 @@ a notification or a person heard audio. Observations reset before each need.
|
|
|
252
252
|
After reloads and rebuilt frames, check waits for online state, completed arrival,
|
|
253
253
|
a hidden loading cover and two frames before assertions/captures. Timeout evidence
|
|
254
254
|
names the surface and selector/effect it awaited. Dialogs are automatically accepted
|
|
255
|
-
and reported. Read retries recover detached frames
|
|
256
|
-
|
|
255
|
+
and reported. Read retries recover detached frames. Click/fill/select retry detached elements by
|
|
256
|
+
re-querying for up to ten seconds; other dispatched action failures are not replayed. `dev` rebuilds root HTML, CSS and JS as well as source and map
|
|
257
257
|
changes. Unavailable sandbox Web Storage uses frame-local memory; use app records
|
|
258
258
|
or `net.prefs` for persistence across reloads.
|
|
259
259
|
|
|
@@ -312,3 +312,19 @@ Issues and grant Notifications Write and Workers Observability Write. Cloudflare
|
|
|
312
312
|
posts signed events to the client's webhook so new or recurring reliability
|
|
313
313
|
problems reach the AI without polling; Homie hosts nothing for this. See
|
|
314
314
|
[Cloudflare's announcement](https://developers.cloudflare.com/changelog/post/2026-10-13-cloudflare-mcp-events/).
|
|
315
|
+
|
|
316
|
+
## Every need has a check
|
|
317
|
+
|
|
318
|
+
Read [NEEDS.md](NEEDS.md) for the complete executable format and examples for UI,
|
|
319
|
+
customer/staff MCP tools, owner event feeds and MCP Events, webhook delivery,
|
|
320
|
+
simulated scheduled functions, records/results and handover files. Keep existing
|
|
321
|
+
UI steps. Headless steps use an explicit named role and the local dev credential;
|
|
322
|
+
assertions check actual returned values and effects. Missing capabilities stay
|
|
323
|
+
not met with their reason. Passing assertions are met even without captures;
|
|
324
|
+
missing evidence names each surface, its error and the `--shots .checks` remedy.
|
|
325
|
+
|
|
326
|
+
Keep interactive DOM nodes connected across delayed refreshes. Update a button's
|
|
327
|
+
text and attributes in place; never replace a button under a press. The short
|
|
328
|
+
example in NEEDS.md preserves its click listener and focus. A stale-element retry
|
|
329
|
+
helps a replaced handle before dispatch; it cannot recover a silently cancelled
|
|
330
|
+
press. Assert the resulting navigation as well as the button's presence.
|
package/lib/app-needs.mjs
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
import {waitInApp} from './app-ready.mjs';
|
|
1
|
+
import {actInApp,waitInApp} from './app-ready.mjs';
|
|
2
2
|
/** Opt-in acceptance evidence, sourced from the person's Codex, not starter labels. */
|
|
3
3
|
import { existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
4
|
-
import { join } from 'node:path';
|
|
4
|
+
import { join, resolve, relative, isAbsolute } from 'node:path';
|
|
5
|
+
import { isDeepStrictEqual } from 'node:util';
|
|
5
6
|
import { createHash } from 'node:crypto';
|
|
6
7
|
|
|
7
8
|
export function readAppNeeds(root, id) {
|
|
@@ -22,30 +23,38 @@ export function readAppNeeds(root, id) {
|
|
|
22
23
|
}
|
|
23
24
|
|
|
24
25
|
/** Run real UI actions. A failed or absent flow stays visible; continue collecting other needs. */
|
|
25
|
-
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {} }) {
|
|
26
|
+
export async function proveAppNeeds({ plan, surfaces, shots, report, ready = async () => {}, root, headless }) {
|
|
26
27
|
const results = [];
|
|
27
28
|
const appBarrier=ready;
|
|
28
|
-
ready=async s=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s);};
|
|
29
|
+
ready=async (s,options)=>{await s.page.bringToFront?.();if(s.external){s.frame=s.page.mainFrame();return;}await appBarrier(s,options);};
|
|
29
30
|
let activeRole;
|
|
30
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; };
|
|
31
32
|
for (const need of plan.needs) {
|
|
32
33
|
activeRole = need.role;
|
|
33
|
-
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface))];
|
|
34
|
+
const names = [...new Set([...(need.steps ?? []), ...(need.assertions ?? [])].map(s => s.surface).filter(Boolean))];
|
|
35
|
+
const saved = new Map();
|
|
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); };
|
|
34
37
|
let waiting = 'validating assertions';
|
|
35
38
|
const result = { id: need.id, statement: need.statement, status: 'not', captures: [] };
|
|
36
39
|
try {
|
|
37
|
-
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires
|
|
40
|
+
if (!Array.isArray(need.assertions) || !need.assertions.length) throw new Error('need requires at least one assertion (UI, result, records or file)');
|
|
38
41
|
const popupNames=new Set((need.steps??[]).map(s=>s.popup).filter(Boolean));
|
|
39
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'}`); }
|
|
40
43
|
for (const name of new Set(need.assertions.filter(a => a.effect).map(a => a.surface))) await surface(name).frame.evaluate(() => { window.__homieNeedsEffects = []; });
|
|
41
|
-
for (const
|
|
44
|
+
for (const rawStep of need.steps ?? []) {
|
|
45
|
+
const step=resolveNeedResults(rawStep,saved);
|
|
46
|
+
if (step.mcp || step.schedule) {
|
|
47
|
+
waiting = step.mcp ? `MCP ${step.mcp.tool ?? step.mcp.method} as ${step.role ?? need.role ?? 'unspecified'}` : `schedule ${step.schedule.name}`;
|
|
48
|
+
const value = await run(step);
|
|
49
|
+
if (step.save) { if(saved.has(step.save))throw new Error(`duplicate saved result ${step.save}`); saved.set(step.save,value); }
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
42
52
|
const target=surface(step.surface);
|
|
43
53
|
if(!step.reload)await ready(target);
|
|
44
54
|
const { page } = target;
|
|
45
55
|
waiting = `${step.surface}: ${step.reload ? 'reload and arrival readiness' : step.selector}`;
|
|
46
56
|
if (step.reload) { await page.reload({ waitUntil: 'domcontentloaded' }); await ready(surface(step.surface)); continue; }
|
|
47
|
-
|
|
48
|
-
const frame=target.frame;
|
|
57
|
+
const act = fn => actInApp(target,step.selector,fn,{ready});
|
|
49
58
|
if (step.click && step.popup) {
|
|
50
59
|
let listener,timer;
|
|
51
60
|
const opened=new Promise((resolve,reject)=>{
|
|
@@ -53,17 +62,42 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
53
62
|
timer=setTimeout(()=>reject(new Error('payment popup did not open')),10000);
|
|
54
63
|
});
|
|
55
64
|
try {
|
|
56
|
-
const [popup]=await Promise.all([opened,frame.click(step.selector)]);
|
|
65
|
+
const [popup]=await Promise.all([opened,act(frame=>frame.click(step.selector))]);
|
|
57
66
|
surfaces.push({name:step.popup,page:popup,frame:popup.mainFrame(),external:true,role:target.role});
|
|
58
67
|
await page.bringToFront();
|
|
59
68
|
} finally {clearTimeout(timer);page.off('popup',listener);}
|
|
60
69
|
}
|
|
61
|
-
else if (step.click) {await frame.click(step.selector);await page.bringToFront?.();}
|
|
62
|
-
else if (step.fill !== undefined) { await frame.$eval(step.selector, el => { el.value = ''; el.dispatchEvent(new Event('input', { bubbles: true })); }); await frame.type(step.selector, String(step.fill)); }
|
|
63
|
-
else if (step.select !== undefined) await frame.select(step.selector, String(step.select));
|
|
70
|
+
else if (step.click) {await act(frame=>frame.click(step.selector));await page.bringToFront?.();}
|
|
71
|
+
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
|
+
else if (step.select !== undefined) await act(frame=>frame.select(step.selector, String(step.select)));
|
|
64
73
|
else throw new Error('step needs click, fill, select or reload');
|
|
65
74
|
}
|
|
66
|
-
for (const
|
|
75
|
+
for (const rawAssertion of need.assertions) {
|
|
76
|
+
const assertion=resolveNeedResults(rawAssertion,saved);
|
|
77
|
+
if (assertion.result || assertion.records || assertion.file || assertion.mcp) {
|
|
78
|
+
waiting = assertion.file ? `file ${assertion.file}` : assertion.result ? `result ${assertion.result}${assertion.path ? '.'+assertion.path : ''}` : assertion.records ? `records ${assertion.records.collection}` : `MCP ${assertion.mcp.tool ?? assertion.mcp.method}`;
|
|
79
|
+
let value;
|
|
80
|
+
if (assertion.file) {
|
|
81
|
+
if (!root) throw new Error('file proof requires the studio root');
|
|
82
|
+
const file=resolve(root,assertion.file), rel=relative(resolve(root),file);
|
|
83
|
+
if(rel.startsWith('..') || isAbsolute(rel))throw new Error('handover file must be inside the studio');
|
|
84
|
+
value=readFileSync(file,'utf8');
|
|
85
|
+
} else if (assertion.result) {
|
|
86
|
+
if(!saved.has(assertion.result))throw new Error(`no saved result ${assertion.result}; add save to its step`);
|
|
87
|
+
value=saved.get(assertion.result);
|
|
88
|
+
} else {
|
|
89
|
+
const waitMs=assertion.waitMs??0;
|
|
90
|
+
if(!Number.isInteger(waitMs)||waitMs<0||waitMs>10000)throw new Error('waitMs must be an integer from 0 to 10000');
|
|
91
|
+
const end=Date.now()+waitMs;
|
|
92
|
+
for(;;) {
|
|
93
|
+
value=await run(assertion);
|
|
94
|
+
try {assertNeedValue(value,assertion);break;}
|
|
95
|
+
catch(error){if(Date.now()>=end)throw error;await new Promise(resolve=>setTimeout(resolve,100));}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
assertNeedValue(value,assertion);
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
67
101
|
waiting = `${assertion.surface}: ${assertion.effect ? assertion.effect + ' ' + (assertion.name ?? '') : assertion.selector + (assertion.text === undefined ? '' : ' containing ' + JSON.stringify(assertion.text))}`;
|
|
68
102
|
const target=surface(assertion.surface);
|
|
69
103
|
await ready(target);
|
|
@@ -77,13 +111,14 @@ export async function proveAppNeeds({ plan, surfaces, shots, report, ready = asy
|
|
|
77
111
|
if (assertion.text !== undefined) await frame.waitForFunction((selector, text) => document.querySelector(selector)?.textContent.includes(text), { timeout: 10000 }, assertion.selector, String(assertion.text));
|
|
78
112
|
},{ready});
|
|
79
113
|
}
|
|
80
|
-
result.status =
|
|
81
|
-
result.why =
|
|
114
|
+
result.status = 'met';
|
|
115
|
+
result.why = 'Assertions passed';
|
|
82
116
|
} catch (error) { result.why = `Waiting for ${waiting}: ${error.message}`; }
|
|
117
|
+
if (!shots) for (const name of names) result.why += `; ${name} capture not requested: rerun check with --shots .checks and inspect the scene`;
|
|
83
118
|
if (shots) for (const name of names) {
|
|
84
119
|
const file = join(shots, `${need.id}-${name}.jpg`);
|
|
85
|
-
try { await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
86
|
-
catch (error) {
|
|
120
|
+
try { mkdirSync(shots,{recursive:true}); await ready(surface(name)); await surface(name).page.screenshot({ path: file, type: 'jpeg', quality: 85 }); result.captures.push(file); }
|
|
121
|
+
catch (error) { result.why += `; ${name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`; }
|
|
87
122
|
}
|
|
88
123
|
report?.check?.(`need-${need.id}`, result.status === 'met' ? 'pass' : 'fail', { label: need.statement, note: result.why });
|
|
89
124
|
results.push(result);
|
|
@@ -96,3 +131,36 @@ export function saveAppNeeds(root, id, plan, needs) {
|
|
|
96
131
|
const dir = join(root, '.studio', 'checks'); mkdirSync(dir, { recursive: true });
|
|
97
132
|
writeFileSync(join(dir, `${id}-needs.json`), JSON.stringify({ at: new Date().toISOString(), codexHash: plan.hash, needs }, null, 2));
|
|
98
133
|
}
|
|
134
|
+
|
|
135
|
+
/** Partial structured matching: object keys must match; each expected array entry must occur. */
|
|
136
|
+
export function containsValue(actual, expected) {
|
|
137
|
+
if (typeof expected === 'string') return typeof actual === 'string' && actual.includes(expected);
|
|
138
|
+
if (Array.isArray(expected)) return Array.isArray(actual) && expected.every(e=>actual.some(a=>containsValue(a,e)));
|
|
139
|
+
if (expected && typeof expected === 'object') return actual !== null && typeof actual === 'object' && Object.entries(expected).every(([k,v])=>Object.hasOwn(actual,k)&&containsValue(actual[k],v));
|
|
140
|
+
return isDeepStrictEqual(actual,expected);
|
|
141
|
+
}
|
|
142
|
+
export function assertNeedValue(value, assertion) {
|
|
143
|
+
if(assertion.path) for(const key of assertion.path.split('.')) value=value!=null&&Object.hasOwn(Object(value),key)?value[key]:undefined;
|
|
144
|
+
if(value === undefined)throw new Error('value does not exist');
|
|
145
|
+
if(Object.hasOwn(assertion,'equals')&&!isDeepStrictEqual(value,assertion.equals))throw new Error(`expected equals ${JSON.stringify(assertion.equals)}; got ${JSON.stringify(value)}`);
|
|
146
|
+
if(Object.hasOwn(assertion,'contains')&&!containsValue(value,assertion.contains))throw new Error(`expected contains ${JSON.stringify(assertion.contains)}; got ${JSON.stringify(value)}`);
|
|
147
|
+
if(assertion.text!==undefined)for(const text of [].concat(assertion.text))if(typeof value!=='string'||!value.includes(String(text)))throw new Error(`expected text ${JSON.stringify(text)}`);
|
|
148
|
+
if(!assertion.file&&!Object.hasOwn(assertion,'equals')&&!Object.hasOwn(assertion,'contains')&&assertion.text===undefined)throw new Error('result/records assertion requires equals, contains or text');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Explicit result references never evaluate code or interpolate arbitrary strings. */
|
|
152
|
+
export function resolveNeedResults(value,saved) {
|
|
153
|
+
if(Array.isArray(value))return value.map(v=>resolveNeedResults(v,saved));
|
|
154
|
+
if(value && typeof value==='object') {
|
|
155
|
+
if(Object.keys(value).length===1 && typeof value.$result==='string') {
|
|
156
|
+
const [name,...path]=value.$result.split('.');
|
|
157
|
+
if(!saved.has(name))throw new Error(`no saved result ${name}`);
|
|
158
|
+
let result=saved.get(name);
|
|
159
|
+
for(const key of path)result=result!=null&&Object.hasOwn(Object(result),key)?result[key]:undefined;
|
|
160
|
+
if(result===undefined)throw new Error(`saved result path ${value.$result} does not exist`);
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
return Object.fromEntries(Object.entries(value).map(([key,v])=>[key,resolveNeedResults(v,saved)]));
|
|
164
|
+
}
|
|
165
|
+
return value;
|
|
166
|
+
}
|
package/lib/app-ready.mjs
CHANGED
|
@@ -48,3 +48,24 @@ export function answerDialogs(page,surface,dialogs,report) {
|
|
|
48
48
|
await dialog.accept().catch(()=>{});
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
|
+
|
|
52
|
+
/** Retry only errors identifying a detached element, never an ambiguous navigation after dispatch. */
|
|
53
|
+
export async function actInApp(surface, selector, action, {ready=appReady,timeout=10000}={}) {
|
|
54
|
+
const end=Date.now()+timeout;
|
|
55
|
+
let stale;
|
|
56
|
+
for (;;) {
|
|
57
|
+
try {
|
|
58
|
+
await waitInApp(surface,selector,frame=>frame.waitForSelector(selector,{visible:true,timeout:Math.max(1,end-Date.now())}),{ready,timeout:Math.max(1,end-Date.now())});
|
|
59
|
+
return await action(surface.frame);
|
|
60
|
+
} catch(error) {
|
|
61
|
+
if(!/node is detached|element.*(?:detach|stale)|stale.*element/i.test(error.message)) {
|
|
62
|
+
if(stale && Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
stale=error;
|
|
66
|
+
if(Date.now()>=end)throw new Error(`${surface.name}: stale element ${selector} never settled within ${timeout}ms: ${error.message}`);
|
|
67
|
+
await new Promise(resolve=>setTimeout(resolve,Math.min(50,Math.max(1,end-Date.now()))));
|
|
68
|
+
await ready(surface,{timeout:Math.max(1,end-Date.now())});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
package/lib/check.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import {needsHeadless} from './needs-headless.mjs';
|
|
1
2
|
import {randomBytes} from 'node:crypto';
|
|
2
|
-
import {appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
|
+
import {actInApp,appReady,observeEffects,waitInApp,answerDialogs} from './app-ready.mjs';
|
|
3
4
|
import {localCredential,localCookie} from './local-test.mjs';
|
|
4
5
|
import { readAppNeeds, proveAppNeeds, saveAppNeeds } from './app-needs.mjs';
|
|
5
6
|
/**
|
|
@@ -148,6 +149,12 @@ export async function check({ isolate = true, root = null, url, game, roundTimeo
|
|
|
148
149
|
if (meta?.rulesError) return { ok: false, command: 'check', why: meta.rulesError };
|
|
149
150
|
if (meta?.kind === 'app') appMeta = meta;
|
|
150
151
|
} catch { /* browser preflight below still names failures */ }
|
|
152
|
+
if(appMeta && !appMeta.check?.action) {
|
|
153
|
+
try {
|
|
154
|
+
const plan=readAppNeeds(root,game);
|
|
155
|
+
if(plan && !plan.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface)))return checkApp({isolate,root,url:base,game,meta:appMeta,shots,log,report});
|
|
156
|
+
} catch(error) {return {ok:false,command:'check',kind:'app',why:error.message};}
|
|
157
|
+
}
|
|
151
158
|
const chrome = findChrome();
|
|
152
159
|
if (!chrome) return { ok: false, command: 'check', why: noChrome() };
|
|
153
160
|
let puppeteer;
|
|
@@ -357,13 +364,13 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
357
364
|
const proof = meta.check??{};
|
|
358
365
|
const baseline=typeof proof.action==='string'&&typeof proof.observe==='string';
|
|
359
366
|
if (!baseline && !plan) return { ok: false, command: 'check', kind: 'app', why: 'app.json check needs action and observe CSS selectors for a real shared action' };
|
|
360
|
-
|
|
367
|
+
const captureWarnings=[];
|
|
361
368
|
const profile = mkdtempSync(join(tmpdir(), 'homie-app-check-'));
|
|
362
369
|
let browser;
|
|
363
370
|
const surfaces = [];
|
|
364
371
|
const room = `check-${randomBytes(10).toString('hex')}`;
|
|
365
372
|
try {
|
|
366
|
-
browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
373
|
+
if(baseline || plan?.needs.some(n=>[...(n.steps??[]),...(n.assertions??[])].some(i=>i.surface))) browser = await puppeteer.launch({ executablePath: chrome, headless: true, userDataDir: profile, timeout: LAUNCH_TIMEOUT_MS, args: chromeArgs() });
|
|
367
374
|
const openSurface = async (name, role) => {
|
|
368
375
|
const dimensions={wall:[1280,720],phone:[390,844],'phone-two':[390,844],tablet:[1024,768],staff:[1024,768],kiosk:[1080,1920]};
|
|
369
376
|
if(!dimensions[name])throw new Error(`Unknown needs surface ${name}`);
|
|
@@ -390,7 +397,7 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
390
397
|
await appReady(actor);
|
|
391
398
|
await waitInApp(actor,proof.action,f=>f.waitForFunction((selector) => { const el = document.querySelector(selector); return el && !el.disabled && !el.hidden; }, {}, proof.action));
|
|
392
399
|
before = await waitInApp(actor,proof.observe,textOf);
|
|
393
|
-
await actor.
|
|
400
|
+
await actInApp(actor,proof.action,f=>f.click(proof.action));
|
|
394
401
|
await waitInApp(actor,proof.observe+' changes after action',f=>f.waitForFunction((selector, was) => document.querySelector(selector)?.textContent !== was, {}, proof.observe, before));
|
|
395
402
|
after = await waitInApp(actor,proof.observe,textOf);
|
|
396
403
|
for (const s of surfaces) await waitInApp(s,proof.observe+' equals '+after,f=>f.waitForFunction((selector, wanted) => document.querySelector(selector)?.textContent === wanted, {}, proof.observe, after));
|
|
@@ -402,19 +409,21 @@ export async function checkApp({ isolate = true, root = null, url, game, meta, s
|
|
|
402
409
|
if (rooms.some((r) => r !== room)) throw new Error('surfaces did not retain the same room');
|
|
403
410
|
for (const s of surfaces) {
|
|
404
411
|
await appReady(s);
|
|
405
|
-
if (shots) await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });
|
|
412
|
+
if (shots) try {mkdirSync(shots,{recursive:true});await s.page.screenshot({ path: join(shots, `${s.name}.jpg`), type: 'jpeg', quality: 85 });}
|
|
413
|
+
catch(error){const warning=`${s.name} capture failed: ${error.message}. Resolve this capture error and rerun check with --shots .checks`;captureWarnings.push(warning);log(warning);}
|
|
406
414
|
}
|
|
407
415
|
report?.check?.('app-action', 'pass', { note: 'action reached wall and both phones; reconnect retained it' });
|
|
408
416
|
}
|
|
409
417
|
for(const need of plan?.needs??[])for(const item of [...(need.steps??[]),...(need.assertions??[])]) {
|
|
418
|
+
if(!item.surface)continue;
|
|
410
419
|
if(plan.needs.some(n=>(n.steps??[]).some(s=>s.popup===item.surface)))continue;
|
|
411
420
|
const role=item.role??need.role??meta.surfaces?.[item.surface]??(item.surface==='staff'?'staff':undefined);
|
|
412
421
|
if(!surfaces.some(s=>s.name===item.surface&&s.role===role))await openSurface(item.surface,role);
|
|
413
422
|
}
|
|
414
|
-
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady }) : null;
|
|
423
|
+
const needs = plan ? await proveAppNeeds({ plan, surfaces, shots, report, ready:appReady, root, headless:needsHeadless({url,secret,app:game,meta,scope:isolate?room:null}) }) : null;
|
|
415
424
|
if (plan) { saveAppNeeds(root, game, plan, needs); const { refreshCodexFile } = await import('./codex.mjs'); refreshCodexFile(root, game); }
|
|
416
425
|
const ok = !needs || needs.every(n => n.status === 'met');
|
|
417
|
-
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
426
|
+
return { ok, ...(needs ? { needs, why: ok ? undefined : 'Some needs from the person’s Codex are not yet met; see needs and captures' } : {}), command: 'check', kind: 'app', isolatedRecords:isolate, captureWarnings, dialogs, room, surfaces: surfaces.map((s) => s.name), action: proof.action, before, after, reconnect: baseline };
|
|
418
427
|
} catch (error) { return { ok: false, command: 'check', kind: 'app', dialogs, why: error.message }; }
|
|
419
428
|
finally { await browser?.close().catch(() => {}); rmSync(profile, { recursive: true, force: true }); }
|
|
420
429
|
}
|
package/lib/codex.mjs
CHANGED
|
@@ -318,7 +318,7 @@ function sectionHtml(section, emb, ctx) {
|
|
|
318
318
|
if (block) {
|
|
319
319
|
try {
|
|
320
320
|
const needs = JSON.parse(block[1]);
|
|
321
|
-
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate
|
|
321
|
+
if (Array.isArray(needs)) return `<section class="tab" id="${esc(section.id)}" data-key="needs"><h2>${esc(section.title)}</h2>${renderMarkdown(source.replace(block[0], '')).html}<div class="cards">${needs.map(n => `<article class="card"><h3>${esc(n.id)}</h3><p>${esc(n.statement)}</p><p class="meta">${Array.isArray(n.assertions) && n.assertions.length ? 'To demonstrate with check' : 'Not yet demonstrable'}</p></article>`).join('')}</div></section>`;
|
|
322
322
|
} catch { /* Keep malformed source visible for correction. */ }
|
|
323
323
|
}
|
|
324
324
|
}
|
|
@@ -582,7 +582,7 @@ export function renderCodex(root, id, { mode = 'file', feed, now = new Date() }
|
|
|
582
582
|
const r = JSON.parse(readFileSync(needReportFile, 'utf8'));
|
|
583
583
|
const stale = r.codexHash !== createHash('sha256').update(readFileSync(file, 'utf8')).digest('hex');
|
|
584
584
|
tabs.push('<a href="#needs-status">Needs status</a>');
|
|
585
|
-
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}.
|
|
585
|
+
needStatus = `<section class="tab" id="needs-status"><h2>Needs status</h2><p>${stale ? 'Plan changed: this evidence is stale; check again. ' : ''}Checked ${esc(r.at)}. Assertions are evidence; review available captures against Their words.</p>${r.needs.map(n => `<article><h3>${esc(n.statement)}</h3><p><strong>${stale ? 'not yet rechecked' : esc(n.status)}</strong> — ${esc(n.why ?? '')}</p>${(n.captures ?? []).map(p => figure(emb, n.statement, relative(root, resolve(root, p)), p)).join('')}</article>`).join('')}</section>`;
|
|
586
586
|
} catch { /* The next check repairs an incomplete receipt. */ }
|
|
587
587
|
}
|
|
588
588
|
const body = needStatus + sections.map((x) => sectionHtml(x, emb, ctx)).join('\n');
|
|
@@ -772,9 +772,11 @@ try: "Open the wall and phone. Follow a real request through the scene."
|
|
|
772
772
|
<!-- What is sold or given, price and currency, once/monthly, and what payment fulfils. -->
|
|
773
773
|
|
|
774
774
|
## Needs
|
|
775
|
-
<!-- Each statement
|
|
776
|
-
Each need: id, statement, steps
|
|
777
|
-
|
|
775
|
+
<!-- Each owner statement belongs in this executable needs block.
|
|
776
|
+
Each need: id, statement, optional role, steps and assertions. UI: surface/selector with
|
|
777
|
+
click/fill/select/reload; headless: mcp tool/arguments + save, or schedule name/at.
|
|
778
|
+
Assert UI selectors, saved result/path/equals/contains, records, or file/text.
|
|
779
|
+
See node_modules/@homie-rocks/studio/guides/app/NEEDS.md for complete examples.
|
|
778
780
|
A missing flow is reported not met. Do not replace an unmet need with an easier one. -->
|
|
779
781
|
\`\`\`needs
|
|
780
782
|
[]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import {localCookie} from './local-test.mjs';
|
|
2
|
+
|
|
3
|
+
/** Each action has an explicit local identity, using the real MCP transport and permissions. */
|
|
4
|
+
export function needsHeadless({url,secret,app,meta,scope}) {
|
|
5
|
+
return async (item,role) => {
|
|
6
|
+
if(!secret)throw new Error('Headless role proof requires homie-studio dev in this studio and its localhost URL');
|
|
7
|
+
if(!role || (role!=='owner'&&!Object.hasOwn(meta.roles??{},role)))throw new Error(`Name a declared role (or owner) for headless proof; got ${role??'none'}`);
|
|
8
|
+
const {Client,StreamableHTTPClientTransport}=await import('mcp-studio-client');
|
|
9
|
+
const client=new Client({name:'Homie needs check',version:'1.0.0'});
|
|
10
|
+
const transport=new StreamableHTTPClientTransport(new URL('/mcp',url),{requestInit:{headers:{authorization:`Bearer ${secret}`,cookie:`homie_test=${localCookie(secret,{app,role,scope})}`}}});
|
|
11
|
+
try {
|
|
12
|
+
await client.connect(transport);
|
|
13
|
+
let call=item.mcp;
|
|
14
|
+
if(item.records)call={tool:'app_records',arguments:{...item.records,app,role,operation:'read'}};
|
|
15
|
+
if(item.schedule)call={tool:'studio_schedule_fire',arguments:item.schedule};
|
|
16
|
+
if(call?.method) {
|
|
17
|
+
if(call.method==='resources/read')return await client.readResource(call.params);
|
|
18
|
+
// The extension's JSON-RPC result is intentionally preserved for path assertions.
|
|
19
|
+
if(['events/list','events/poll'].includes(call.method)) {
|
|
20
|
+
const {z}=await import('zod');
|
|
21
|
+
return await client.request({method:call.method,params:call.params??{}},z.object({}).passthrough(),{timeout:10000});
|
|
22
|
+
}
|
|
23
|
+
throw new Error(`Unsupported MCP proof method ${call.method}; use tools, resources/read, events/list or events/poll`);
|
|
24
|
+
}
|
|
25
|
+
if(!call?.tool)throw new Error('MCP step requires tool and optional arguments');
|
|
26
|
+
const result=await client.callTool({name:call.tool,arguments:call.arguments??{}},undefined,{timeout:item.schedule?15000:10000});
|
|
27
|
+
if(result.isError)throw new Error(result.content?.filter(c=>c.type==='text').map(c=>c.text).join('; ')||'MCP tool failed');
|
|
28
|
+
if(result.structuredContent!==undefined)return result.structuredContent;
|
|
29
|
+
const content=result.content?.filter(c=>c.type==='text');
|
|
30
|
+
if(content?.length===1) {try {const parsed=JSON.parse(content[0].text);return parsed.kind==='untrusted-tool-data'?parsed.data:parsed;}catch{}}
|
|
31
|
+
return result;
|
|
32
|
+
} finally {await client.close();}
|
|
33
|
+
};
|
|
34
|
+
}
|
package/lib/shop-links.mjs
CHANGED
|
@@ -78,7 +78,7 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
78
78
|
const menuItems = shop.items.filter(i => typeof i.price === 'number' && i.price > 0 && !i.interval);
|
|
79
79
|
const metadata = { homie: 'shop-links-v1', studio: slug, origin: site };
|
|
80
80
|
const groups=Map.groupBy(menuItems,i=>i.game??'');
|
|
81
|
-
for(const group of groups.values()) for (let offset = 0; offset < group.length; offset += 10) {
|
|
81
|
+
for(const group of (shop.basket ? [] : groups.values())) for (let offset = 0; offset < group.length; offset += 10) {
|
|
82
82
|
const items = group.slice(offset, offset + 10);
|
|
83
83
|
if (items.length < 2) continue;
|
|
84
84
|
const params = { ...customText, line_items: items.map(i => ({price:catalogPrices.get(i.id),quantity:1,adjustable_quantity:{enabled:true,minimum:0,maximum:shop.policy.repeatPurchases?999999:1}})),
|
|
@@ -93,6 +93,32 @@ export async function reconcilePaymentLinks({ api, shop, slug, site, mode, booki
|
|
|
93
93
|
keepLinks.add(link.id);
|
|
94
94
|
config.menus.push({id:link.id,url:link.url,revision,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
95
95
|
}
|
|
96
|
+
// One aggregate payment represents a frozen local basket, including one-item baskets.
|
|
97
|
+
// Runtime uses prefilled_amount, never a shared Price mutation: concurrent
|
|
98
|
+
// guests must receive their own saved totals.
|
|
99
|
+
if(shop.basket) for(const [game,items] of groups) {
|
|
100
|
+
const id=await productIdOf(slug,`basket-${game||'shop'}`);
|
|
101
|
+
keepProducts.add(id);
|
|
102
|
+
const fields={name:shop.basket.name,description:shop.basket.description,metadata,active:true};
|
|
103
|
+
const product=products.find(p=>p.id===id);
|
|
104
|
+
if(!product)await api('POST','/v1/products',{id,...fields},`product-${hash(id)}`);
|
|
105
|
+
else if(product.name!==fields.name || product.description!==fields.description || !product.active)await api('POST',`/v1/products/${id}`,fields);
|
|
106
|
+
const priceFields={product:id,currency:shop.currency,custom_unit_amount:{enabled:true},tax_behavior:'inclusive'};
|
|
107
|
+
const priceRevision=hash(priceFields);
|
|
108
|
+
let price=prices.find(p=>own(p)&&p.product===id&&p.metadata?.revision===priceRevision);
|
|
109
|
+
if(!price)price=await api('POST','/v1/prices',{...priceFields,metadata:{...metadata,revision:priceRevision}},`price-${hash({priceFields,metadata})}`);
|
|
110
|
+
if(!price.active)await api('POST',`/v1/prices/${price.id}`,{active:true});
|
|
111
|
+
keepPrices.add(price.id);
|
|
112
|
+
const params={line_items:[{price:price.id,quantity:1}],automatic_tax:{enabled:false},payment_intent_data:{metadata},
|
|
113
|
+
after_completion:{type:'redirect',redirect:{url:`${site}/shop/thanks?session_id={CHECKOUT_SESSION_ID}`}}};
|
|
114
|
+
const revision=hash(params);
|
|
115
|
+
let link=links.find(p=>own(p)&&p.metadata?.revision===revision);
|
|
116
|
+
if(!link)link=await api('POST','/v1/payment_links',{...params,metadata:{...metadata,revision}},`basket-${revision}`);
|
|
117
|
+
if(!link.active)await api('POST',`/v1/payment_links/${link.id}`,{active:true});
|
|
118
|
+
if(!paymentLinkURL(link.url))throw new Error('Invalid basket Payment Link');
|
|
119
|
+
keepLinks.add(link.id);
|
|
120
|
+
config.menus.push({id:link.id,url:link.url,revision,price:price.id,basket:true,items:items.map(i=>({item:i.id,price:catalogPrices.get(i.id)}))});
|
|
121
|
+
}
|
|
96
122
|
config.slots=[];
|
|
97
123
|
const pause=async link=>{
|
|
98
124
|
if(link.active) await api('POST',`/v1/payment_links/${link.id}`,{active:false});
|
|
@@ -3753,6 +3753,48 @@
|
|
|
3753
3753
|
}
|
|
3754
3754
|
}
|
|
3755
3755
|
}
|
|
3756
|
+
},
|
|
3757
|
+
"0.51.6": {
|
|
3758
|
+
"files": {
|
|
3759
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
3760
|
+
"README.md": "684b3cca8001673c",
|
|
3761
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
3762
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
3763
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
3764
|
+
"videos/README.md": "16babc3437d5545b",
|
|
3765
|
+
"posts/README.md": "37841aae476bd540",
|
|
3766
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
3767
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
3768
|
+
},
|
|
3769
|
+
"sections": {
|
|
3770
|
+
"AGENTS.md": {
|
|
3771
|
+
"(intro)": "394cbbe934e80722",
|
|
3772
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
3773
|
+
"## Layout": "f3a280b8c96cd068",
|
|
3774
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
3775
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
3776
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
3777
|
+
"## Making games": "bb715272fd1a615b",
|
|
3778
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
3779
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
3780
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
3781
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
3782
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
3783
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
3784
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
3785
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
3786
|
+
"## Beta": "9d02638207a26e73"
|
|
3787
|
+
}
|
|
3788
|
+
},
|
|
3789
|
+
"named": {
|
|
3790
|
+
"Homie Arcade": {
|
|
3791
|
+
"sections": {
|
|
3792
|
+
"AGENTS.md": {
|
|
3793
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
3794
|
+
}
|
|
3795
|
+
}
|
|
3796
|
+
}
|
|
3797
|
+
}
|
|
3756
3798
|
}
|
|
3757
3799
|
}
|
|
3758
3800
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.51.
|
|
3
|
+
"version": "0.51.6",
|
|
4
4
|
"description": "A studio for games, music and video on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
package/shop/SHOP.md
CHANGED
|
@@ -371,3 +371,47 @@ dev bindings and disposable studios when finished.
|
|
|
371
371
|
See `apps/APPS.md`, “Real Stripe test checkout on this computer”, for the supported
|
|
372
372
|
`shop connect --local` and Stripe CLI forwarding recipe. Every paid monthly cycle
|
|
373
373
|
creates a paid order and `order.paid` with the subscription’s original app reference.
|
|
374
|
+
|
|
375
|
+
## Preserve a local basket with keyless checkout
|
|
376
|
+
|
|
377
|
+
For a venue that wants the phone's exact items and quantities, opt into a saved
|
|
378
|
+
basket in `shop.json` before syncing Payment Links:
|
|
379
|
+
|
|
380
|
+
```json
|
|
381
|
+
"basket": {
|
|
382
|
+
"name": "Round for your table",
|
|
383
|
+
"description": "Enter the total shown on your pub basket. Any shortfall is settled at the bar."
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
Use Stripe (`till: "stripe"`) and tax-inclusive menu prices (`automaticTax: false`).
|
|
388
|
+
Sync creates one custom-amount Price and Payment Link per app, with one line and
|
|
389
|
+
quantity one. `shop.checkout({reference: "table-7"})` saves the validated basket
|
|
390
|
+
lines, quantities, total and reference in the studio's D1 before opening Stripe.
|
|
391
|
+
Even a basket containing only one item keeps its exact quantity. The opaque order
|
|
392
|
+
ID travels as `client_reference_id`; no Stripe API key or Homie payment relay is
|
|
393
|
+
used by the Worker. Existing menus without `basket` retain their behavior.
|
|
394
|
+
|
|
395
|
+
The link prefills the saved total using Stripe's documented
|
|
396
|
+
[`prefilled_amount`](https://docs.stripe.com/payment-links/customize#customize-checkout-with-url-parameters)
|
|
397
|
+
in minor currency units. The guest can edit it, so a preset is never payment proof.
|
|
398
|
+
[`custom_unit_amount.preset`](https://docs.stripe.com/api/prices/create?query=custom_unit_amount)
|
|
399
|
+
is the Price-level fallback, not a per-buyer setting. Never mutate that shared Price
|
|
400
|
+
at checkout: concurrent baskets would race. Both the Price fallback and the URL
|
|
401
|
+
override were verified in the Homie sandbox. Invalid overrides fall back to the
|
|
402
|
+
stored preset or an empty amount; keep the expected total visible on the phone.
|
|
403
|
+
|
|
404
|
+
After a signed paid session matches the frozen link, revision, mode, currency and
|
|
405
|
+
order reference, `order.paid` contains the **saved basket lines**, `amount` (actual
|
|
406
|
+
payment), `basketTotal` (expected total), and `settlement` (`paid` or `short`).
|
|
407
|
+
Line `amount` is the saved extended amount (unit price × quantity); line `total`
|
|
408
|
+
is its proportionally allocated share of the actual aggregate payment for refunds.
|
|
409
|
+
A short payment emits the event so staff can resolve it, but grants no automatic
|
|
410
|
+
entitlements. Apps must compare the actual payment with `basketTotal`, show a
|
|
411
|
+
shortfall explicitly, and deduplicate by order ID. More than the total is paid.
|
|
412
|
+
Reopening and paying the link creates another order for the same frozen basket;
|
|
413
|
+
repeated delivery of the same session never creates another round.
|
|
414
|
+
|
|
415
|
+
The Stripe heading is the connected Stripe account's business name. A sandbox
|
|
416
|
+
account named “Homie sandbox” shows that name; a real pub's account shows the pub's
|
|
417
|
+
name. The basket's product name and description use the pub's own wording.
|
package/worker/app-records.mjs
CHANGED
|
@@ -47,6 +47,7 @@ export async function appAccess(request, env, meta, url) {
|
|
|
47
47
|
const caller = toolIdentity(request);
|
|
48
48
|
if (caller) {
|
|
49
49
|
if (caller.owner) return { ok: true, role, can: declared.can };
|
|
50
|
+
if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest && caller.roles?.[meta.id]?.includes(role))return {ok:true,role,can:declared.can};
|
|
50
51
|
if (!caller.id) return { ok: false, status: 401, error: 'sign in to this studio' };
|
|
51
52
|
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();
|
|
52
53
|
return grant ? { ok: true, role, can: declared.can } : { ok: false, status: 403, error: 'this account has no role grant' };
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.51.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.51.6","sha256":"035298d6bd38bab86e0471ea08819786995a53cedb8da961b595506e5403b410"});
|
|
@@ -25,10 +25,16 @@ export async function readableFeed(env,cat,caller,name) {
|
|
|
25
25
|
// Non-record events have no app read capability; they remain owner-only.
|
|
26
26
|
return caller.owner===true;
|
|
27
27
|
}
|
|
28
|
-
export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER}={}) {
|
|
28
|
+
export async function feedEvents(env,name,{after=0,through=Number.MAX_SAFE_INTEGER,caller}={}) {
|
|
29
29
|
const feed=eventFeeds(env)[name];if(!feed)return [];
|
|
30
|
-
const
|
|
31
|
-
|
|
30
|
+
const scopedApp=env.HOMIE_LOCAL_TEST_SECRET&&caller?.localTest&&caller.testScope&&feed.event==='record.changed'?`${feed.app}:check:${caller.testScope}`:null;
|
|
31
|
+
const rows=(await env.DB.prepare("SELECT seq,id,type,data,at FROM studio_events WHERE type=?1 AND seq>?2 AND seq<=?3 AND (?4 IS NULL OR json_extract(data,'$.app')=?4) ORDER BY seq LIMIT 100").bind(feed.event,after,through,scopedApp).all()).results;
|
|
32
|
+
const events=rows.map(r=>({...r,data:JSON.parse(r.data)}));
|
|
33
|
+
if(env.HOMIE_LOCAL_TEST_SECRET && caller?.localTest && caller.testScope && feed.event==='record.changed') {
|
|
34
|
+
const app=`${feed.app}:check:${caller.testScope}`;
|
|
35
|
+
return events.filter(e=>e.data.app===app).map(e=>({...e,data:{...e.data,app:feed.app}}));
|
|
36
|
+
}
|
|
37
|
+
return events;
|
|
32
38
|
}
|
|
33
39
|
export function matchesFeed(feed,event) {
|
|
34
40
|
return feed.event===event.type && (feed.event!=='record.changed'||(event.data.app===feed.app&&event.data.collection===feed.collection));
|
package/worker/local-test.mjs
CHANGED
|
@@ -2,6 +2,10 @@
|
|
|
2
2
|
const local = (request, env) => env.HOMIE_LOCAL_TEST_SECRET && ['localhost','127.0.0.1','[::1]'].includes(new URL(request.url).hostname);
|
|
3
3
|
export async function localCaller(request, env) {
|
|
4
4
|
if (!local(request, env) || request.headers.get('authorization') !== `Bearer ${env.HOMIE_LOCAL_TEST_SECRET}`) return null;
|
|
5
|
+
const session=await localSession(request,env);
|
|
6
|
+
// A supplied but invalid signed session must never fall back to owner.
|
|
7
|
+
if(request.headers.get('cookie')?.includes('homie_test=')&&!session)return null;
|
|
8
|
+
if(session)return {id:`local-test-${session.app}-${session.role}`,owner:session.role==='owner',client:null,localTest:true,roles:{[session.app]:[session.role]},testApp:session.app,testScope:session.scope??null};
|
|
5
9
|
return {id:'local-test-owner',owner:true,client:null};
|
|
6
10
|
}
|
|
7
11
|
export async function localSession(request, env) {
|
package/worker/mcp-events.mjs
CHANGED
|
@@ -41,9 +41,9 @@ export async function pollEvents(env,cat,caller,params,now=Date.now()){
|
|
|
41
41
|
const rows=(await env.DB.prepare(`SELECT seq,id,type,data,at FROM studio_events
|
|
42
42
|
WHERE type=?1 AND seq>?2 AND seq<=?3
|
|
43
43
|
AND (?4 IS NULL OR (json_extract(data,'$.app')=?4 AND json_extract(data,'$.collection')=?5))
|
|
44
|
-
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?feed.app:null,feed.collection??null,max+1).all()).results;
|
|
44
|
+
ORDER BY seq LIMIT ?6`).bind(feed.event,start.after,start.through,feed.event==='record.changed'?(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope?`${feed.app}:check:${caller.testScope}`:feed.app):null,feed.collection??null,max+1).all()).results;
|
|
45
45
|
const hasMore=rows.length>max,page=rows.slice(0,max);
|
|
46
|
-
const events=page.map(row=>occurrence(params.name,{...row,data
|
|
46
|
+
const events=page.map(row=>{const data=JSON.parse(row.data);if(env.HOMIE_LOCAL_TEST_SECRET&&caller.localTest&&caller.testScope&&feed.event==='record.changed')data.app=feed.app;return occurrence(params.name,{...row,data},cursorOf(params.name,row.seq));});
|
|
47
47
|
const after=hasMore?page.at(-1).seq:start.through;
|
|
48
48
|
return {events,cursor:cursorOf(params.name,after),truncated:start.truncated,hasMore,nextPollMs:5000};
|
|
49
49
|
}
|
package/worker/mcp-tools.mjs
CHANGED
|
@@ -14,6 +14,7 @@ export async function allowed(audience, caller, env) {
|
|
|
14
14
|
if (audience === 'signed-in') return true;
|
|
15
15
|
if (audience === 'owner') return caller.owner === true;
|
|
16
16
|
if (audience && typeof audience === 'object' && audience.app && audience.role) {
|
|
17
|
+
if(env.HOMIE_LOCAL_TEST_SECRET && caller.localTest)return caller.owner || Boolean(caller.roles?.[audience.app]?.includes(audience.role));
|
|
17
18
|
return caller.owner || Boolean(await env.DB.prepare("SELECT player FROM app_roles WHERE app IN (?1,'*') AND role=?2 AND player=?3").bind(audience.app,audience.role,caller.id).first());
|
|
18
19
|
}
|
|
19
20
|
return false;
|
|
@@ -86,7 +87,7 @@ export function builtins(env,cat,origin) {
|
|
|
86
87
|
const {readableFeed,feedEvents,matchesFeed,eventFeeds}=await import('./event-resources.mjs');
|
|
87
88
|
if(!await readableFeed(env,cat,ctx.caller,feed))throw new Error('Event feed unavailable');
|
|
88
89
|
if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
|
|
89
|
-
const page=await feedEvents(env,feed,{after});
|
|
90
|
+
const page=await feedEvents(env,feed,{after,caller:ctx.caller});
|
|
90
91
|
return {kind:'untrusted-event-data',events:page.filter(e=>matchesFeed(eventFeeds(env)[feed],e)),next:page.length===100?page.at(-1).seq:null};
|
|
91
92
|
}),
|
|
92
93
|
tool('studio_events_webhook_status','Owner delivery attempts, last errors and next page for an outbound event webhook.','owner',object({feed:str,after:{type:'integer',minimum:0}},['feed']),async({feed,after},ctx)=>{
|
|
@@ -105,6 +106,23 @@ export function builtins(env,cat,origin) {
|
|
|
105
106
|
const {replayFunction,runFunctions}=await import('./functions.mjs');
|
|
106
107
|
await replayFunction(env,name);await runFunctions(env,cat,origin);return {ok:true};
|
|
107
108
|
}),
|
|
109
|
+
tool('studio_schedule_fire','Locally invoke a named scheduled function at a simulated timestamp.','owner',object({name:str,at:{type:'integer',minimum:0}},['name','at']),async({name,at},ctx)=>{
|
|
110
|
+
if(!env.HOMIE_LOCAL_TEST_SECRET || !['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local dev testing only');
|
|
111
|
+
const {syncFunctions,emitEvent,runFunctions}=await import('./functions.mjs');
|
|
112
|
+
const definitions=await syncFunctions(env), fn=definitions.find(f=>f.name===name&&f.schedule);
|
|
113
|
+
if(!fn)throw new Error(`Unknown scheduled function ${name}; declare it and rebuild dev`);
|
|
114
|
+
const data={function:name,cron:fn.schedule,scheduledTime:at,...(ctx.caller.testScope?{app:`${ctx.caller.testApp}:check:${ctx.caller.testScope}`}:{})};
|
|
115
|
+
const id=await emitEvent(env,fn.event,data);
|
|
116
|
+
await runFunctions(env,cat,origin);
|
|
117
|
+
let run;const end=Date.now()+10000;
|
|
118
|
+
do {
|
|
119
|
+
run=await env.DB.prepare('SELECT outcome,error FROM function_runs WHERE event=?1 AND function=?2 ORDER BY started DESC LIMIT 1').bind(id,name).first();
|
|
120
|
+
if(run && !['running','interrupted'].includes(run.outcome))break;
|
|
121
|
+
await new Promise(resolve=>setTimeout(resolve,50));
|
|
122
|
+
}while(Date.now()<end);
|
|
123
|
+
if(!run || run.outcome!=='complete')throw new Error(`Schedule ${name}: ${run?.error??run?.outcome??'no handler run'}`);
|
|
124
|
+
return {id,name,scheduledTime:at,simulated:true};
|
|
125
|
+
}),
|
|
108
126
|
tool('studio_function_fire','Fire an event locally through the durable function dispatcher.','owner',object({event:str,id:str,data:{type:'object'}},['event']),async(args)=>{
|
|
109
127
|
if(!['localhost','127.0.0.1','[::1]'].includes(new URL(origin).hostname))throw new Error('Local testing only');
|
|
110
128
|
const {emitEvent,runFunctions}=await import('./functions.mjs');
|
package/worker/mcp.mjs
CHANGED
|
@@ -82,7 +82,7 @@ export async function remoteMcp(request,env,ctx,{catalogueOf,definitions=[]}) {
|
|
|
82
82
|
const readPage=async(resourceUri,after=0)=>read('events:'+name,async fresh=>{
|
|
83
83
|
if(!await readableFeed(env,cat,fresh,name))throw new Error('Event resource unavailable');
|
|
84
84
|
if(!Number.isSafeInteger(after)||after<0)throw new Error('Invalid event cursor');
|
|
85
|
-
const page=await feedEvents(env,name,{after});
|
|
85
|
+
const page=await feedEvents(env,name,{after,caller:fresh});
|
|
86
86
|
const events=page.filter(e=>matchesFeed(eventFeeds(env)[name],e));
|
|
87
87
|
return {contents:[{uri:String(resourceUri),mimeType:'application/json',text:JSON.stringify({kind:'untrusted-event-data',events,next:page.length===100?uri+'?after='+page.at(-1).seq:null})}]};
|
|
88
88
|
});
|
package/worker/shop-links.mjs
CHANGED
|
@@ -9,7 +9,7 @@ export function linkConfig(env) {
|
|
|
9
9
|
export const shopMode = (env) => hasStripeKey(env) ? modeOf(env.STRIPE_KEY) : linkConfig(env)?.mode ?? 'test';
|
|
10
10
|
export function linkSettings(shop) {
|
|
11
11
|
return JSON.stringify({ currency: shop.currency, till: shop.till, automaticTax: shop.automaticTax, policy: shop.policy,
|
|
12
|
-
cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items });
|
|
12
|
+
cap: shop.capPerPlayerMonth, checkoutMinutes: shop.checkoutMinutes, items: shop.items, ...(shop.basket ? {basket:shop.basket} : {}) });
|
|
13
13
|
}
|
|
14
14
|
export function fullerReason(shop) {
|
|
15
15
|
if (shop.items.some(item => item.interval && item.price === 'choose')) return 'A giver-chosen monthly amount needs a Stripe Checkout connection.';
|
package/worker/shop-rules.mjs
CHANGED
|
@@ -209,7 +209,10 @@ export function checkShop(raw, { games = null, audience = 'general', studioName
|
|
|
209
209
|
const kids = audience === 'kids' && !policy.kidsStudio;
|
|
210
210
|
const finalTill = kids ? 'off' : TILLS.includes(till) ? till : 'off';
|
|
211
211
|
if (kids && till !== 'off') warnings.push({ at: 'till', message: 'studio.json says "audience": "kids": the studio sells nothing in its games (the till is off under policy.kidsStudio).' });
|
|
212
|
+
if (raw.basket !== undefined && (!isObj(raw.basket) || typeof raw.basket.name !== "string" || !raw.basket.name.trim() || raw.basket.name.length > 120 || typeof raw.basket.description !== "string" || raw.basket.description.length > 500)) errors.push({at:"basket",message:"A basket needs a name (1–120 characters) and description (up to 500 characters)."});
|
|
213
|
+
if (raw.basket && (raw.automaticTax || till !== "stripe")) errors.push({at:"basket",message:"Saved baskets use Stripe with tax already included in the menu prices."});
|
|
212
214
|
const shop = {
|
|
215
|
+
...(isObj(raw.basket) ? {basket:{name:raw.basket.name,description:raw.basket.description}} : {}),
|
|
213
216
|
v: 1, ...(isObj(raw.giving) ? { giving: { receiptText: String(raw.giving.receiptText ?? '') } } : {}), policy, purchasesTill: TILLS.includes(raw.purchasesTill ?? till) ? (raw.purchasesTill ?? till) : 'off', ...(/^https:\/\/billing\.stripe\.com\/p\/login\/[A-Za-z0-9_]+$/.test(raw.purchasesPortalLogin ?? '') ? { purchasesPortalLogin: raw.purchasesPortalLogin } : {}), checkoutMinutes, requestBytes, automaticTax: raw.automaticTax === true, guestBuyersPerAddressPerHour, purchaseAttemptsPerMinute, purchaseAttemptsPerAddressPerMinute, till: finalTill, currency: CURRENCY.test(currency) ? currency : 'usd', refundDays, capPerPlayerMonth: cap, items,
|
|
214
217
|
open: finalTill !== 'off' && items.length > 0, ...(kids ? { audience: 'kids' } : {}), ...(catalog.length ? { catalog } : {}),
|
|
215
218
|
};
|
package/worker/shop.mjs
CHANGED
|
@@ -517,7 +517,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
517
517
|
const keyless = (amount > 0 || lines.some(l => l.item.interval)) && !hasStripeKey(env);
|
|
518
518
|
const config=linkConfig(env);
|
|
519
519
|
if(booking && amount===0 && !hasStripeKey(env) && config?.slots?.some(l=>l.app===game&&l.slots===booking.slots&&l.slot===booking.slot))return {error:fail(409,'booking','Publish a separate local slot for a free booking; it cannot share a paid Stripe capacity budget.')};
|
|
520
|
-
const menu=keyless && !booking && lines.length>
|
|
520
|
+
const menu=keyless && !booking && lines.length>0 ? config?.menus?.find(m=>(m.basket || lines.length>1)&&lines.every(l=>m.items.some(i=>i.item===l.item.id))) : null;
|
|
521
521
|
const link=keyless ? booking ? config?.slots?.find(l=>l.app===game&&l.collection===booking.collection&&l.slots===booking.slots&&l.slot===booking.slot&&l.item===item.id) : menu ?? config?.items.find(l=>l.item===item.id) : null;
|
|
522
522
|
if(keyless && lines.length>1 && !menu)return {error:fail(409,'menu','These items need separate Stripe menus. Custom amounts and monthly gifts use their own links.')};
|
|
523
523
|
if(keyless && (!link || !paymentLinkURL(link.url)))return {error:fail(503,'payment-links','Ask the owner’s AI to sync the shop and published booking slots.')};
|
|
@@ -533,7 +533,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
533
533
|
}
|
|
534
534
|
if(keyless && item.price==='choose' && lines[0].quantity!==1)return {error:fail(400,'quantity','A tip is one chosen amount; choose it on Stripe.')};
|
|
535
535
|
if(menu && menu.items.some(e=>{const i=shop.items.find(i=>i.id===e.item);return !i || i.game && i.game!==game;}))return {error:fail(409,'menu','This menu includes items from another app; sync separate menus for each app.')};
|
|
536
|
-
if(menu)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
|
|
536
|
+
if(menu && !menu.basket)lines=menu.items.map(entry=>{const i=shop.items.find(i=>i.id===entry.item);return {item:i,quantity:1,amount:i.price};});
|
|
537
537
|
if(keyless)lines=lines.map(l=>({...l,item:{...l.item,paymentLink:link,stripePrice:menu?.items.find(i=>i.item===l.item.id)?.price??link.price}}));
|
|
538
538
|
const order = { id: newOrderId(), player: player.id, amount, currency: shop.currency };
|
|
539
539
|
if (booking && reference === null) reference = order.id;
|
|
@@ -575,6 +575,7 @@ async function startCheckout(env, url, cat, shop, item, { player, game, amount,
|
|
|
575
575
|
}
|
|
576
576
|
if (keyless) {
|
|
577
577
|
const checkout = new URL(link.url); checkout.searchParams.set('client_reference_id', order.id);
|
|
578
|
+
if(menu?.basket)checkout.searchParams.set('prefilled_amount',String(amount));
|
|
578
579
|
return { order, url: checkout.href, reconciled };
|
|
579
580
|
}
|
|
580
581
|
let session = null;
|
|
@@ -955,6 +956,7 @@ async function bindLinkSession(env, session, mode) {
|
|
|
955
956
|
if (!source || source.mode !== mode) return false;
|
|
956
957
|
const lines = await orderLines(env, source.id);
|
|
957
958
|
if (!lines.length || !lines[0].snapshot) return false;
|
|
959
|
+
if(JSON.parse(lines[0].snapshot).paymentLink?.basket) return bindBasketSession(env,session,source,lines);
|
|
958
960
|
if(JSON.parse(lines[0].snapshot).paymentLink?.items) {
|
|
959
961
|
const saved=await env.DB.prepare('SELECT value FROM meta WHERE key=?1').bind(`shop-menu:${source.id}`).first();
|
|
960
962
|
const original=saved?JSON.parse(saved.value).map(l=>({item:l.item.id,snapshot:JSON.stringify(l.item),unit_amount:l.amount})):lines;
|
|
@@ -990,6 +992,50 @@ async function bindLinkSession(env, session, mode) {
|
|
|
990
992
|
return true;
|
|
991
993
|
}
|
|
992
994
|
|
|
995
|
+
/** A saved basket is immutable. A reopened link buys that same basket again. */
|
|
996
|
+
async function bindBasketSession(env,session,source,lines) {
|
|
997
|
+
const link=JSON.parse(lines[0].snapshot).paymentLink;
|
|
998
|
+
if((session.payment_link?.id??session.payment_link)!==link.id || session.metadata?.revision!==link.revision || session.currency!==source.currency)return false;
|
|
999
|
+
if(session.payment_status!=='paid' || !Number.isSafeInteger(session.amount_total) || session.amount_total<=0 || session.amount_subtotal!==session.amount_total)return false;
|
|
1000
|
+
await env.DB.prepare('UPDATE shop_orders SET session=?2 WHERE id=?1 AND session IS NULL').bind(source.id,session.id).run();
|
|
1001
|
+
let order=await orderBySession(env,session.id);
|
|
1002
|
+
if(!order) {
|
|
1003
|
+
const id=`ord_${(await sha256Hex(session.id)).slice(0,20)}`;
|
|
1004
|
+
await env.DB.batch([
|
|
1005
|
+
env.DB.prepare("INSERT OR IGNORE INTO shop_orders(id,player,checkout_player,item,game,amount,currency,till,mode,status,parent,created_at,updated_at,session) SELECT ?2,player,checkout_player,item,game,amount,currency,till,mode,'started',parent,?4,?4,?3 FROM shop_orders WHERE id=?1").bind(source.id,id,session.id,Date.now()),
|
|
1006
|
+
env.DB.prepare('INSERT OR IGNORE INTO meta(key,value) SELECT ?1,value FROM meta WHERE key=?2').bind(`shop-reference:${id}`,`shop-reference:${source.id}`),
|
|
1007
|
+
...lines.map((l,i)=>env.DB.prepare('INSERT OR IGNORE INTO shop_order_lines(id,order_id,position,item,quantity,unit_amount,amount,snapshot) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)').bind(`${id}_${i}`,id,i,l.item,l.quantity,l.unit_amount,l.amount,l.snapshot)),
|
|
1008
|
+
]);
|
|
1009
|
+
order=await orderBySession(env,session.id);
|
|
1010
|
+
}
|
|
1011
|
+
return Boolean(order);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// Keep the actual payment in financial totals and the ordered amounts in lines.
|
|
1015
|
+
// Aggregate refunds are allocated proportionally; they do not change quantities.
|
|
1016
|
+
async function paidBasket(env,shop,o,lines,session,paidAt) {
|
|
1017
|
+
const total=session.amount_total;
|
|
1018
|
+
if(session.payment_status!=='paid' || session.currency!==o.currency || !Number.isSafeInteger(total) || total<=0 || session.amount_subtotal!==total)return 'mismatch';
|
|
1019
|
+
if(o.paid_at!==null)return 'already';
|
|
1020
|
+
const expected=lines.reduce((n,l)=>n+Number(l.amount),0);
|
|
1021
|
+
if(!Number.isSafeInteger(expected)||expected<=0)return 'mismatch';
|
|
1022
|
+
const writes=[];let cumulative=0,allocated=0;
|
|
1023
|
+
for(const l of lines) {
|
|
1024
|
+
cumulative+=Number(l.amount);
|
|
1025
|
+
const next=Number(BigInt(total)*BigInt(cumulative)/BigInt(expected));
|
|
1026
|
+
const gross=next-allocated;allocated=next;
|
|
1027
|
+
if(total>=expected && o.player)writes.push(...grantStatements(env,o,JSON.parse(l.snapshot),paidAt,l));
|
|
1028
|
+
writes.push(env.DB.prepare("UPDATE shop_order_lines SET status='paid',total=?2 WHERE id=?1 AND EXISTS(SELECT 1 FROM shop_orders WHERE id=?3 AND paid_at IS NULL)").bind(l.id,gross,o.id));
|
|
1029
|
+
}
|
|
1030
|
+
const payment=typeof session.payment_intent==='string'?session.payment_intent:session.payment_intent?.id;
|
|
1031
|
+
writes.push(env.DB.prepare("UPDATE shop_orders SET status='paid',amount=?2,total=?2,tax=0,payment=?3,paid_at=?4,updated_at=?4,attention=0,note=?5 WHERE id=?1 AND paid_at IS NULL").bind(o.id,total,payment??null,paidAt,total<expected?'Settle the difference at the bar':''));
|
|
1032
|
+
// Enrich the trigger's durable event in this same transaction, before dispatch.
|
|
1033
|
+
writes.push(env.DB.prepare("UPDATE studio_events SET data=json_set(data,'$.basketTotal',?2,'$.settlement',?3) WHERE id=?1").bind(`shop_orders:${o.id}:paid`,expected,total<expected?'short':'paid'));
|
|
1034
|
+
const result=await env.DB.batch(writes);
|
|
1035
|
+
if(payment)await syncRefunds(env,payment);
|
|
1036
|
+
return result.at(-2)?.meta?.changes?'paid':'already';
|
|
1037
|
+
}
|
|
1038
|
+
|
|
993
1039
|
/** Menu totals are never decoded into quantities. Only signed invoice lines are authoritative. */
|
|
994
1040
|
async function bindMenuSession(env,session,source,lines) {
|
|
995
1041
|
const first=JSON.parse(lines[0].snapshot),link=first.paymentLink;
|
|
@@ -1029,7 +1075,9 @@ export async function paid(env, shop, session, paidAt = Date.now()) {
|
|
|
1029
1075
|
const o = await orderBySession(env, session.id);
|
|
1030
1076
|
if (!o) return 'unknown-order';
|
|
1031
1077
|
// The session must be the one this order opened, for this player and this price.
|
|
1032
|
-
const
|
|
1078
|
+
const savedLines=await orderLines(env,o.id);
|
|
1079
|
+
if(savedLines[0]?.snapshot && JSON.parse(savedLines[0].snapshot).paymentLink?.basket)return paidBasket(env,shop,o,savedLines,session,paidAt);
|
|
1080
|
+
const linkOrder = savedLines.some((l) => l.snapshot && JSON.parse(l.snapshot).paymentLink);
|
|
1033
1081
|
if (!linkOrder && (session.metadata?.order !== o.id || ((o.checkout_player ?? o.player) && session.client_reference_id && session.client_reference_id !== (o.checkout_player ?? o.player)))) return 'mismatch';
|
|
1034
1082
|
if (session.currency !== o.currency || !Number.isSafeInteger(session.amount_subtotal) || session.amount_subtotal !== Number(o.amount)) return 'mismatch';
|
|
1035
1083
|
if (['refunded', 'lost', 'disputed'].includes(o.status)) {
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.51.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.51.6';
|