@homie-rocks/studio 0.50.6 → 0.51.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +62 -0
- package/app-starters/giving/README.md +25 -2
- package/app-starters/giving/app.json +16 -49
- package/app-starters/giving/index.html +1 -1
- package/app-starters/giving/src/rules.ts +1 -1
- package/app-starters/giving/src/view.ts +57 -59
- package/apps/APPS.md +70 -2
- package/apps/apps.ts +1 -1
- package/bin/homie-studio.mjs +8 -4
- package/functions/FUNCTIONS.md +39 -2
- package/guides/app/SKILL.md +61 -5
- package/guides/functions/SKILL.md +30 -0
- package/guides/office/SKILL.md +20 -0
- package/guides/servers/SKILL.md +18 -0
- package/guides/shop/SKILL.md +41 -20
- package/lib/app-needs.mjs +31 -11
- package/lib/app-ready.mjs +49 -0
- package/lib/check.mjs +51 -35
- package/lib/dev-stripe-fixture.mjs +37 -0
- package/lib/dev-stripe-sandbox.mjs +80 -0
- package/lib/dev.mjs +19 -17
- package/lib/events-cli.mjs +5 -1
- package/lib/local-test.mjs +18 -0
- package/lib/mcp-tools.mjs +15 -1
- package/lib/office.mjs +1 -1
- package/lib/scaffold.mjs +22 -1
- package/lib/shop-links.mjs +79 -7
- package/lib/shop.mjs +4 -2
- package/lib/stripe-connect.mjs +1 -0
- package/lib/template-history.json +84 -0
- package/lib/tools-cli.mjs +3 -0
- package/package.json +1 -1
- package/shop/SELLING.md +2 -2
- package/shop/SHOP.md +120 -5
- package/shop/shop.ts +29 -0
- package/tools/tools.ts +8 -1
- package/worker/app-format.mjs +8 -1
- package/worker/app-frame.mjs +9 -0
- package/worker/app-records.mjs +39 -10
- package/worker/booking-store.mjs +79 -0
- package/worker/build-id.mjs +1 -1
- package/worker/event-resources.mjs +30 -3
- package/worker/functions.mjs +5 -2
- package/worker/index.mjs +17 -3
- package/worker/local-test.mjs +19 -0
- package/worker/mcp-tools.mjs +19 -8
- package/worker/mcp.mjs +23 -11
- package/worker/records-page.mjs +13 -0
- package/worker/shop-links.mjs +3 -2
- package/worker/shop-page.mjs +27 -5
- package/worker/shop-rules.mjs +2 -1
- package/worker/shop-store.mjs +1 -1
- package/worker/shop-subscriptions.mjs +198 -0
- package/worker/shop.mjs +195 -29
- package/worker/tool-database.mjs +36 -0
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,68 @@ 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.0] - 2026-10-14
|
|
21
|
+
|
|
22
|
+
**Plugin 0.51.0** · Take the money through your own Stripe, keyless by default.
|
|
23
|
+
|
|
24
|
+
One Stripe approval enables mixed menus, paid capacity bookings and monthly giving through the owner’s own account.
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Mixed-item Stripe menus with adjustable quantities, signed paid invoice lines
|
|
29
|
+
and app references such as table 7 on `order.paid`.
|
|
30
|
+
- Booking collections with pending/confirmed/released records and versioned staff
|
|
31
|
+
moves. Each published slot has its own Stripe Payment Link with a completed-session
|
|
32
|
+
capacity limit; the owner's AI syncs slots with `stripe_bookings_sync`.
|
|
33
|
+
- Recurring Payment Links for monthly giving and memberships, paid invoice periods,
|
|
34
|
+
subscription lifecycle events and an API-configured no-code customer portal login.
|
|
35
|
+
- Real sandbox acceptance for menus, two guests with the last-seat link open,
|
|
36
|
+
full-link deactivation, monthly payment, portal, and clock-driven renewal/failure.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
|
|
40
|
+
- The one-approval CLI connection remains keyless. Homie has no platform credential,
|
|
41
|
+
backend or cut in the payment path. A key alone never selects session checkout;
|
|
42
|
+
`STRIPE_CHECKOUT_MODE=sessions` explicitly opts into the fuller connection.
|
|
43
|
+
- Keyless unpaid bookings are pending, not exclusive holds. Payment secures the seat.
|
|
44
|
+
The optional keyed path retains exclusive expiring holds and exact app-built carts.
|
|
45
|
+
- The giving starter opens real Checkout for suggested one-time/monthly gifts and
|
|
46
|
+
disables payment before connection. It never counts intentions as gifts.
|
|
47
|
+
|
|
48
|
+
### Fixed
|
|
49
|
+
|
|
50
|
+
- Records-only app shells include the shop bridge without needing a multiplayer room.
|
|
51
|
+
- Signed menu invoices and Checkout events tolerate either arrival order, and paid
|
|
52
|
+
membership access never extends on a failed renewal or revives after cancellation.
|
|
53
|
+
|
|
54
|
+
### Upgrade notes
|
|
55
|
+
|
|
56
|
+
Existing studios keep their pinned version until they opt in. Apply booking and
|
|
57
|
+
subscription migrations when adding those capabilities. Run connect after changing
|
|
58
|
+
shop items; the owner's AI syncs published slot manifests after schedule changes.
|
|
59
|
+
Stripe's Payment Link and invoice snapshot limits split large catalogues into menus;
|
|
60
|
+
custom amounts and monthly presets use separate links. No studio catalogue cap is added.
|
|
61
|
+
|
|
62
|
+
## [0.50.7] - 2026-10-14
|
|
63
|
+
|
|
64
|
+
**Plugin 0.50.7** · Prove your needs on every screen, with fixes from studio testers.
|
|
65
|
+
|
|
66
|
+
Your app checks can show that your staff, tablet, kiosk, phone and wall workflows meet the needs you wrote down. Local checks use fresh records, and everyday studio work is more reliable.
|
|
67
|
+
|
|
68
|
+
### Fixed
|
|
69
|
+
|
|
70
|
+
- App checks use the screens and staff roles named in your needs, verify sound, vibration and notification requests, and wait for the app to be ready after a reload. If a check cannot finish, it tells you which screen and condition it was waiting for.
|
|
71
|
+
- Your AI can test local functions and tools without opening a browser, including the first event and its app or booking references. Staff access across your studio works alongside access granted to individual apps.
|
|
72
|
+
- Functions can safely update shared records, append entries and increment counters when several requests arrive together. Your AI can read event feeds through tools, discover feed changes, and clear history through a point you choose.
|
|
73
|
+
- Local previews refresh when you edit your app's HTML, CSS or JavaScript. Records-only apps open without game rules or a room, and embedded apps can keep temporary browser storage when persistent storage is unavailable. Checks handle confirmation dialogs and rebuilt app frames; local staff links use localhost.
|
|
74
|
+
- Invalid records explain which field or ID needs fixing. Tool failures keep their useful explanation while hiding credentials. On narrow screens without 3D support, the fallback message leaves the headline readable; command help points to your app's files.
|
|
75
|
+
- Shop and giving previews keep your items and amounts visible before payments are connected, with checkout disabled. A new studio can preview an empty shop.
|
|
76
|
+
|
|
77
|
+
### Upgrade notes
|
|
78
|
+
|
|
79
|
+
- Upgrade, build and redeploy your studio to apply migration `0024_tool_data_versions.sql`, which lets functions detect conflicting record updates. This release adds no studio quota or automatic history deletion.
|
|
80
|
+
- Local app checks use isolated records and private local staff sessions by default. Use `check --no-isolate` when you deliberately want to check existing records. Production sign-in stays the same.
|
|
81
|
+
|
|
20
82
|
## [0.50.6] - 2026-10-14
|
|
21
83
|
|
|
22
84
|
**Plugin 0.50.6** · Scheduled functions run, and your AI can see every run.
|
|
@@ -1,5 +1,28 @@
|
|
|
1
1
|
# Together we can
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
This starter opens real Checkout for one-time or monthly gifts from your studio's
|
|
4
|
+
shop. Run `shop init --giving --currency CAD`, then connect the organisation's
|
|
5
|
+
Stripe. The default gift options include $20 monthly. Existing studios and apps
|
|
6
|
+
are unchanged; generate this starter to opt in.
|
|
4
7
|
|
|
5
|
-
|
|
8
|
+
The phone reads the actual shop's donation options and availability. Without
|
|
9
|
+
Stripe, the options are a disabled preview (`screen.shopPreview: true`). A chosen-amount item (`price: 'choose'`)
|
|
10
|
+
shows an amount field using the item's minimum and maximum. See `shop/SHOP.md`
|
|
11
|
+
for the keyless Payment Links setup and “give $20 monthly” example.
|
|
12
|
+
|
|
13
|
+
Checkout opening never increments a total or creates an intention record. Stripe's
|
|
14
|
+
verified payment supplies `order.paid` and `subscription.started`; subsequent
|
|
15
|
+
invoices supply `subscription.renewed` or `subscription.failed`. Connect these
|
|
16
|
+
events to your own public progress display if desired. The starter deliberately
|
|
17
|
+
shows no collected total until you implement that verified projection.
|
|
18
|
+
|
|
19
|
+
The Manage my gifts link opens the studio shop, where the giver can open their
|
|
20
|
+
own Stripe customer portal. Connect creates the no-code portal login through Stripe's API; no Dashboard toggle is needed.
|
|
21
|
+
Set `giving.receiptText` in shop.json for your organisation's receipt wording.
|
|
22
|
+
Edit app.json name, blurb and colour for your cause. Phone: /<id>/open; wall:
|
|
23
|
+
/<id>/tv. Capture and check both surfaces before sharing.
|
|
24
|
+
|
|
25
|
+
Add the cause’s actual needs to its CODEX.md before running `check`. This starter
|
|
26
|
+
has no pretend shared-record action for the checker to click. For a disconnected
|
|
27
|
+
preview, assert `[data-action]:disabled` on the phone; verify paid flows with the
|
|
28
|
+
Checkout harness and signed events once the keyless connection is available.
|
|
@@ -1,29 +1,20 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "giving",
|
|
3
3
|
"name": "Together we can",
|
|
4
|
-
"blurb": "Support our
|
|
4
|
+
"blurb": "Support our work with a one-time or monthly gift, paid securely through Stripe.",
|
|
5
5
|
"entry": "src/view.ts",
|
|
6
6
|
"roles": {
|
|
7
7
|
"customer": {
|
|
8
8
|
"signIn": false,
|
|
9
|
-
"can": [
|
|
10
|
-
"read:entries",
|
|
11
|
-
"create:entries"
|
|
12
|
-
]
|
|
9
|
+
"can": []
|
|
13
10
|
},
|
|
14
11
|
"wall": {
|
|
15
12
|
"signIn": false,
|
|
16
|
-
"can": [
|
|
17
|
-
"read:entries"
|
|
18
|
-
]
|
|
13
|
+
"can": []
|
|
19
14
|
},
|
|
20
15
|
"staff": {
|
|
21
16
|
"signIn": true,
|
|
22
|
-
"can": [
|
|
23
|
-
"read:entries",
|
|
24
|
-
"update:entries",
|
|
25
|
-
"delete:entries"
|
|
26
|
-
]
|
|
17
|
+
"can": []
|
|
27
18
|
}
|
|
28
19
|
},
|
|
29
20
|
"surfaces": {
|
|
@@ -37,14 +28,14 @@
|
|
|
37
28
|
"join": "Scan to join",
|
|
38
29
|
"one": "person",
|
|
39
30
|
"many": "people",
|
|
40
|
-
"action": "
|
|
41
|
-
"total": "
|
|
31
|
+
"action": "Give on Stripe",
|
|
32
|
+
"total": "Your gift",
|
|
42
33
|
"choice": "Choose an option",
|
|
43
34
|
"monthly": "Gift frequency",
|
|
44
35
|
"once": "One time",
|
|
45
36
|
"month": "Monthly",
|
|
46
37
|
"give": "Give securely on Stripe",
|
|
47
|
-
"goal": "
|
|
38
|
+
"goal": "Our goal",
|
|
48
39
|
"prompt": "Choose what works for you.",
|
|
49
40
|
"finished": "All three answers are in. Thank you!",
|
|
50
41
|
"complete": "Mark complete",
|
|
@@ -54,33 +45,6 @@
|
|
|
54
45
|
"live": "Together \u00b7 live",
|
|
55
46
|
"reconnecting": "Reconnecting\u2026"
|
|
56
47
|
},
|
|
57
|
-
"records": {
|
|
58
|
-
"persist": true,
|
|
59
|
-
"collections": {
|
|
60
|
-
"entries": {
|
|
61
|
-
"fields": {
|
|
62
|
-
"choice": {
|
|
63
|
-
"type": "string",
|
|
64
|
-
"required": true,
|
|
65
|
-
"maxLength": 100
|
|
66
|
-
},
|
|
67
|
-
"status": {
|
|
68
|
-
"type": "string",
|
|
69
|
-
"required": true,
|
|
70
|
-
"maxLength": 60
|
|
71
|
-
},
|
|
72
|
-
"amount": {
|
|
73
|
-
"type": "number",
|
|
74
|
-
"required": true
|
|
75
|
-
},
|
|
76
|
-
"monthly": {
|
|
77
|
-
"type": "boolean",
|
|
78
|
-
"required": true
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
},
|
|
84
48
|
"parts": [],
|
|
85
49
|
"netplay": {
|
|
86
50
|
"v": 1,
|
|
@@ -100,7 +64,8 @@
|
|
|
100
64
|
"watch": "overview",
|
|
101
65
|
"screen": {
|
|
102
66
|
"join": "bottom-right",
|
|
103
|
-
"share": "top-right"
|
|
67
|
+
"share": "top-right",
|
|
68
|
+
"shopPreview": true
|
|
104
69
|
},
|
|
105
70
|
"needs": {
|
|
106
71
|
"three": "0.185.1",
|
|
@@ -108,12 +73,9 @@
|
|
|
108
73
|
"@homie-rocks/geom": "0.1.1",
|
|
109
74
|
"@types/three": "0.185.1"
|
|
110
75
|
},
|
|
111
|
-
"check": {
|
|
112
|
-
"action": "[data-action]",
|
|
113
|
-
"observe": "[data-count]"
|
|
114
|
-
},
|
|
76
|
+
"check": {},
|
|
115
77
|
"landing": {
|
|
116
|
-
"pitch": "Support our
|
|
78
|
+
"pitch": "Support our work with a one-time or monthly gift, paid securely through Stripe."
|
|
117
79
|
},
|
|
118
80
|
"room": {
|
|
119
81
|
"host": "server"
|
|
@@ -126,6 +88,7 @@
|
|
|
126
88
|
"kind": "giving",
|
|
127
89
|
"choices": [
|
|
128
90
|
"10",
|
|
91
|
+
"20",
|
|
129
92
|
"25",
|
|
130
93
|
"50"
|
|
131
94
|
],
|
|
@@ -158,5 +121,9 @@
|
|
|
158
121
|
]
|
|
159
122
|
}
|
|
160
123
|
]
|
|
124
|
+
},
|
|
125
|
+
"records": {
|
|
126
|
+
"persist": false,
|
|
127
|
+
"collections": {}
|
|
161
128
|
}
|
|
162
129
|
}
|
|
@@ -1,3 +1,3 @@
|
|
|
1
1
|
<!doctype html><html lang="en"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Live together</title><style>
|
|
2
|
-
*{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,a{font:inherit;min-height:48px;border-radius:12px}button,select{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:
|
|
2
|
+
*{box-sizing:border-box}body{margin:0;background:var(--tone,#183b55);color:#fff7df;font-family:system-ui,sans-serif}main{min-height:100svh;padding:clamp(24px,5vw,64px);display:grid;grid-template-columns:1.1fr 1fr;gap:40px;align-items:center}h1{font-size:clamp(44px,6vw,88px);line-height:1;letter-spacing:-.05em;margin:20px 0}p{line-height:1.6;max-width:40em}#connection{font-size:12px;text-transform:uppercase;letter-spacing:.18em}.panel{background:#fff7df;color:#20352e;border-radius:28px;padding:28px;box-shadow:0 30px 80px #0003}#count{font-size:80px;font-weight:800;line-height:1}ul{padding:0;list-style:none}li{padding:12px 0;border-bottom:1px solid #20352e22}button,select,input,a{font:inherit;min-height:48px;border-radius:12px}button,select,input{padding:10px 16px;width:100%;margin-top:8px}button{background:var(--tone);color:#fff7df;border:0;cursor:pointer}button:disabled{opacity:.5}a{display:inline-flex;align-items:center;color:inherit}label{display:block;margin-top:14px}select,input{background:white;color:#20352e;border:1px solid #20352e66}progress{width:100%;height:22px;accent-color:var(--tone)}#status{min-height:24px}#amount{font-size:40px;font-weight:700}button:focus-visible,select:focus-visible,a:focus-visible{outline:3px solid #df9325;outline-offset:4px}.wall #controls{display:none}#giving-link[hidden],[hidden]{display:none!important}@media(max-width:650px){main{display:block;padding:72px 22px 24px}h1{font-size:46px}.panel{margin-top:24px;padding:22px}#count{font-size:clamp(28px,9vw,46px)}li{font-size:14px}}@media(max-height:520px) and (min-width:651px){main{padding:65px 25px 20px;gap:22px}h1{font-size:38px}.panel{padding:18px}}
|
|
3
3
|
</style><main><header><span id="connection"></span><h1></h1><p id="intro"></p><div id="goal"><p id="goal-label"></p><div><span id="amount"></span> / <span id="target"></span></div><progress></progress></div><ul id="entries" aria-live="polite"></ul></header><section class="panel"><div id="label"></div><div id="count" data-count>0</div><div id="controls"><p id="prompt"></p><label><span id="choice-label"></span><select id="choice"></select></label><label id="frequency"><span id="frequency-label"></span><select id="monthly"><option id="once" value="once"></option><option id="month" value="month"></option></select></label><button data-action></button><a id="giving-link" href="/shop/" target="_blank" rel="noopener"></a></div><p id="status" role="status"></p></section></main><script type="module" src="./assets/main.js"></script></html>
|
|
@@ -15,7 +15,7 @@ export default defineRules({
|
|
|
15
15
|
},
|
|
16
16
|
room: {
|
|
17
17
|
rounds: { seconds: 0, breakSeconds: 0 }, bots: { keep: 0 },
|
|
18
|
-
join() { return { kind: 'participant', at: { x: 0, y: 0 } }; },
|
|
18
|
+
join() { return { kind: 'participant', at: { x: 0, y: 0, z: 0 } }; },
|
|
19
19
|
on: { refresh(world) { world.shared.revision += 1; world.emit('refresh', { x: 0, y: 0 }, {}); } },
|
|
20
20
|
},
|
|
21
21
|
});
|
|
@@ -1,74 +1,72 @@
|
|
|
1
1
|
import app from '../app.json';
|
|
2
2
|
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
3
3
|
import type rules from './rules';
|
|
4
|
-
import {
|
|
5
|
-
import { randomId } from '@homie-rocks/studio/links';
|
|
6
|
-
type Entry = { choice: string; status: string; amount: number; monthly: boolean };
|
|
4
|
+
import { createShop, type ShopCatalog } from '@homie-rocks/studio/shop';
|
|
7
5
|
const room = openRoom<typeof rules>();
|
|
8
|
-
const
|
|
9
|
-
const role = net.params.role ?? 'customer';
|
|
10
|
-
const words: Record<string, string> = app.words;
|
|
11
|
-
const config = app.starter;
|
|
6
|
+
const shop = createShop();
|
|
12
7
|
const $ = <T extends HTMLElement = HTMLElement>(s: string) => document.querySelector<T>(s)!;
|
|
13
|
-
const records = createAppRecords<Entry>({ collection: 'entries' });
|
|
14
|
-
let rows: AppRecord<Entry>[] = [], busy = false, ready = false, question = 0;
|
|
15
8
|
const action = $<HTMLButtonElement>('[data-action]');
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
$('
|
|
23
|
-
$('#
|
|
24
|
-
$('#
|
|
25
|
-
$('#
|
|
26
|
-
$('#
|
|
27
|
-
$('#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
choices();
|
|
9
|
+
const choice = $<HTMLSelectElement>('#choice');
|
|
10
|
+
const frequency = $<HTMLSelectElement>('#monthly');
|
|
11
|
+
let catalog: ShopCatalog | undefined, busy = false;
|
|
12
|
+
const wall = room.net.params.role === 'wall';
|
|
13
|
+
document.body.className = room.net.params.role ?? 'customer';
|
|
14
|
+
document.body.style.setProperty('--tone', app.starter.color);
|
|
15
|
+
$('h1').textContent = app.name;
|
|
16
|
+
$('#intro').textContent = app.blurb;
|
|
17
|
+
$('#label').textContent = wall ? 'Give with us' : 'Your gift';
|
|
18
|
+
$('#choice-label').textContent = 'Choose a gift';
|
|
19
|
+
$('#frequency-label').textContent = 'Gift frequency';
|
|
20
|
+
$('#once').textContent = 'One time'; $('#month').textContent = 'Monthly';
|
|
21
|
+
$('#giving-link').textContent = 'Manage my gifts';
|
|
22
|
+
$('#goal').hidden = true;
|
|
23
|
+
$('#entries').hidden = true;
|
|
24
|
+
$('#prompt').textContent = 'Choose what works for you. Payments go directly to our Stripe account.';
|
|
25
|
+
const amountLabel = document.createElement('label'); amountLabel.textContent = 'Your amount';
|
|
26
|
+
const amount = document.createElement('input'); amount.type = 'number'; amount.inputMode = 'decimal'; amountLabel.append(amount);
|
|
27
|
+
choice.parentElement!.after(amountLabel);
|
|
28
|
+
function selected() { return catalog?.items.find(i => i.id === choice.value); }
|
|
37
29
|
function paint() {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
li.textContent = `${row.data.choice} · ${row.data.status}${row.data.monthly ? ' · ' + words.month : ''}`;
|
|
49
|
-
if (role === 'staff' && row.data.status !== words.done) {
|
|
50
|
-
const button = document.createElement('button'); button.textContent = words.complete;
|
|
51
|
-
button.onclick = () => { void records.update(row, { ...row.data, status: words.done }).then(refresh).catch(e => { $('#status').textContent = e.message; }); }; li.append(button);
|
|
52
|
-
}
|
|
53
|
-
$('#entries').append(li);
|
|
30
|
+
const item = selected();
|
|
31
|
+
amountLabel.hidden = item?.price !== 'choose';
|
|
32
|
+
action.disabled = busy || !catalog?.open || !item || item.way === 'closed';
|
|
33
|
+
action.textContent = frequency.value === 'month' ? 'Give monthly on Stripe' : 'Give on Stripe';
|
|
34
|
+
$('[data-count]').textContent = wall ? 'Thank you' : item?.shown ?? 'Preview';
|
|
35
|
+
if (item?.price === 'choose') {
|
|
36
|
+
const scale = catalog!.currencyScale;
|
|
37
|
+
amount.min = String((item.min ?? 0) / scale);
|
|
38
|
+
amount.max = item.max == null ? '' : String(item.max / scale);
|
|
39
|
+
amount.step = String(1 / scale);
|
|
54
40
|
}
|
|
55
41
|
}
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
42
|
+
function options() {
|
|
43
|
+
choice.replaceChildren();
|
|
44
|
+
for (const item of catalog?.items ?? []) {
|
|
45
|
+
if (item.kind !== 'donation' || Boolean(item.interval) !== (frequency.value === 'month')) continue;
|
|
46
|
+
const option = document.createElement('option'); option.value = item.id; option.textContent = `${item.name} · ${item.shown}`; choice.append(option);
|
|
47
|
+
}
|
|
48
|
+
paint();
|
|
59
49
|
}
|
|
50
|
+
choice.onchange = paint; frequency.onchange = options;
|
|
60
51
|
action.onclick = async () => {
|
|
52
|
+
const item = selected(); if (!item || !catalog?.open) return;
|
|
53
|
+
const value = item.price === 'choose' ? Math.round(Number(amount.value) * catalog.currencyScale) : undefined;
|
|
54
|
+
if (item.price === 'choose' && (!amount.value || !Number.isSafeInteger(value) || value! < (item.min ?? 0) || item.max != null && value! > item.max)) {
|
|
55
|
+
$('#status').textContent = 'Enter an amount within the displayed range.'; return;
|
|
56
|
+
}
|
|
61
57
|
busy = true; paint();
|
|
62
58
|
try {
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
$('#status').textContent =
|
|
66
|
-
if (
|
|
67
|
-
|
|
68
|
-
} catch (error) { $('#status').textContent = (error as Error).message; }
|
|
69
|
-
finally { busy = false; paint(); }
|
|
59
|
+
// Opening Checkout is not a payment. Only Stripe's verified webhook records a gift.
|
|
60
|
+
const opened = await shop.buy(item.id, 1, value);
|
|
61
|
+
$('#status').textContent = opened ? 'Finish your gift on Stripe. Your receipt confirms payment.' : 'Open the shop to sign in or complete the steps for this gift.';
|
|
62
|
+
if (!opened) shop.open(item.id);
|
|
63
|
+
} finally { busy = false; paint(); }
|
|
70
64
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
async function refresh() {
|
|
66
|
+
catalog = await shop.catalog(); options();
|
|
67
|
+
$('#status').textContent = wall ? 'Join on your phone to choose a one-time or monthly gift.' : catalog.open ? 'Monthly gifts renew until you cancel through Stripe. Manage your gifts using the link below.' : 'Preview — giving is unavailable until the organisation connects Stripe.';
|
|
68
|
+
$('#connection').textContent = room.net.link === 'online' ? 'Together · live' : 'Connecting…';
|
|
69
|
+
room.net.playable();
|
|
70
|
+
}
|
|
71
|
+
shop.on('closed', () => { void refresh(); });
|
|
74
72
|
void refresh();
|
package/apps/APPS.md
CHANGED
|
@@ -43,13 +43,13 @@ The QR encoder is the existing wall encoder, publicly exported with types. Links
|
|
|
43
43
|
|
|
44
44
|
## Parts, check and stores
|
|
45
45
|
|
|
46
|
-
Parts import exactly as games do (`@parts/<id>` or `@parts/<host>/<id>`), with the same provenance and credit checks. `check.action` and `check.observe` name real UI selectors: the checker clicks on a phone, waits for changed text on wall and both phones, then reloads the actor and checks again. `check.params` optionally supplies public URL parameters (for example `{ "ticket": "404" }`) to all three surfaces so the action can address an existing sample record. The checker always chooses its own room. No round hooks are required.
|
|
46
|
+
Parts import exactly as games do (`@parts/<id>` or `@parts/<host>/<id>`), with the same provenance and credit checks. `check.action` and `check.observe` name real UI selectors: the checker clicks on a phone, waits for changed text on wall and both phones, then reloads the actor and checks again. `check.params` optionally supplies public URL parameters (for example `{ "ticket": "404" }`) to all three surfaces so the action can address an existing sample record. The checker always chooses its own room. No round hooks are required. `check` uses fresh isolated records by default; run dev and check its localhost URL. `--no-isolate` explicitly uses existing records. A Codex-only needs plan can omit this baseline action.
|
|
47
47
|
|
|
48
48
|
`homie-studio standalone plan/build <app-id>` reuses Electron/Capacitor and the same built bundle. Public customer record calls use the existing standalone origin allowlist; credentials are never shared across origins. Native staff sign-in, private collections, offline record writes, native billing and automatic store upload are not implemented. Existing game standalone behavior is unchanged. A store build still needs its normal local SDK, signing and store review.
|
|
49
49
|
|
|
50
50
|
## Shop button on the screen
|
|
51
51
|
|
|
52
|
-
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to
|
|
52
|
+
An open shop with items for this game or app shows **Shop** above its play/open screen. The shell reserves 52 pixels plus the safe area above the content, so the button never covers its controls. In `game.json` or `app.json`, set `"screen": { "shop": false }` to disable the experience’s shop (merge with any existing screen settings). Keep it true or omitted when your app uses its own `createShop` buttons; false also refuses their checkout bridge requests. Closed shops, kids policies and an empty selection show no button.
|
|
53
53
|
|
|
54
54
|
```ts
|
|
55
55
|
import { createShop } from '@homie-rocks/studio/shop';
|
|
@@ -59,3 +59,71 @@ itemButton.onclick = () => shop.open('supporter');
|
|
|
59
59
|
```
|
|
60
60
|
|
|
61
61
|
These calls open the same shop in games and apps. Inside a player card, purchases leave for the studio's shop; ordinary play/open pages start Stripe checkout. The studio's TV and kids policies still apply.
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
`check` uses fresh isolated records by default and prints that choice. Run `dev`,
|
|
66
|
+
then check its **localhost** URL with the studio root available. All screens in a
|
|
67
|
+
run share its isolated records; later runs start empty. `--no-isolate` explicitly
|
|
68
|
+
uses existing records (including when checking a live URL). Do not assume existing
|
|
69
|
+
bookings are fixtures: create the needed records through the flow. The baseline
|
|
70
|
+
`app.json check` action is optional when the Codex contains executable needs.
|
|
71
|
+
|
|
72
|
+
`dev` creates a private local test credential. The checker signs each screen into
|
|
73
|
+
the role the need names without a passkey ceremony, and still enforces that role’s
|
|
74
|
+
record capabilities. This credential only works on loopback with the dev-only
|
|
75
|
+
Worker binding; production account sign-in is unchanged. Use `app role <id> staff
|
|
76
|
+
--player <account> --studio` for a studio-wide grant; omit `--studio` for one app.
|
|
77
|
+
Staff links printed locally use localhost. A grant can be revoked at either scope.
|
|
78
|
+
|
|
79
|
+
Example: `{"id":"fit","statement":"My crew fits boards from a tablet","role":"staff",
|
|
80
|
+
"steps":[{"surface":"tablet","selector":"[data-fit]","click":true}],
|
|
81
|
+
"assertions":[{"surface":"tablet","selector":"[data-state]","text":"Fitted"},
|
|
82
|
+
{"surface":"tablet","effect":"sound","name":"audio"}]}`.
|
|
83
|
+
|
|
84
|
+
Nonvisual assertions observe actual platform calls: `sound` (`audio` for Web Audio
|
|
85
|
+
source starts, including the audio package, or `media` for media play), `haptic`
|
|
86
|
+
(`vibrate`), and `notification` (the notification title). Omit `name` to accept any
|
|
87
|
+
request of that type. An app-specific adapter may emit `homie-effect` with
|
|
88
|
+
`{effect,name}` at its real trigger. These prove requests, not that an OS delivered
|
|
89
|
+
a notification or a person heard audio. Observations reset before each need.
|
|
90
|
+
|
|
91
|
+
After reloads and rebuilt frames, check waits for online state, completed arrival,
|
|
92
|
+
a hidden loading cover and two frames before assertions/captures. Timeout evidence
|
|
93
|
+
names the surface and selector/effect it awaited. Dialogs are automatically accepted
|
|
94
|
+
and reported. Read retries recover detached frames; dispatched actions are not
|
|
95
|
+
silently repeated. `dev` rebuilds root HTML, CSS and JS as well as source and map
|
|
96
|
+
changes. Unavailable sandbox Web Storage uses frame-local memory; use app records
|
|
97
|
+
or `net.prefs` for persistence across reloads.
|
|
98
|
+
|
|
99
|
+
Record fields start with a lowercase letter and use lowercase letters, digits and
|
|
100
|
+
underscore. Record IDs use 1–64 letters, digits, underscores or hyphens. `app_schema`
|
|
101
|
+
and the `app_records` tool schema explain the contract; validation errors name it.
|
|
102
|
+
|
|
103
|
+
Live and local unconnected shop/giving pages display the declared items and amounts
|
|
104
|
+
with disabled payment controls. They say they are not taking payments yet. A fresh
|
|
105
|
+
`/shop/` has an empty preview; an authored shop page still takes precedence.
|
|
106
|
+
|
|
107
|
+
## Booking capacity
|
|
108
|
+
|
|
109
|
+
See `shop/SHOP.md` for “book a tune and hold the slot”. A collection opts in with
|
|
110
|
+
`booking: {slots: 'slots', item: 'full-tune'}`. Slots are ordinary staff-managed
|
|
111
|
+
records with integer `capacity`. Bookings are server-managed records created by
|
|
112
|
+
`shop.book`; their public data is only slot, quantity and state. Keep personal
|
|
113
|
+
information in a separate private collection. Public roles must not have write
|
|
114
|
+
capabilities for slots or bookings. Staff movement is a versioned record update
|
|
115
|
+
containing only `slot`; confirmation and release follow verified Checkout state.
|
|
116
|
+
The default keyless connection uses one Payment Link per published slot. Call
|
|
117
|
+
`stripe_bookings_sync` after publishing/changing slots. Pending checkout does not
|
|
118
|
+
block others; payment secures the seat. Use record.booking.full/available/synced
|
|
119
|
+
for slot display. Read SHOP.md for the complete manifest and limitations.
|
|
120
|
+
|
|
121
|
+
The giving starter now reads the actual shop donation options with `shop.catalog()`.
|
|
122
|
+
`shop init --giving --currency CAD` includes $20 monthly; `app new giving --from giving`
|
|
123
|
+
opens Checkout for the selected amount and frequency. A disconnected shop displays
|
|
124
|
+
a disabled preview. No intention record or unverified collected total is created.
|
|
125
|
+
Use the Manage my gifts link for the buyer-owned Stripe portal in the shop.
|
|
126
|
+
|
|
127
|
+
Records-only apps support the same shop bridge as room apps. In the sandboxed
|
|
128
|
+
view, use `createShop({target: parent})` to send checkout and booking requests to
|
|
129
|
+
the authenticated outer shell; a room simulation is not required for payments.
|
package/apps/apps.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** Lasting records through the same sandbox bridge used by saves and the shop. */
|
|
2
2
|
import { randomId } from '../links/links.mjs';
|
|
3
|
-
export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number }
|
|
3
|
+
export interface AppRecord<T> { id: string; data: T; version: number; updatedAt: number; booking?: {full: boolean; available: number; synced: boolean} }
|
|
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> }>();
|
package/bin/homie-studio.mjs
CHANGED
|
@@ -246,7 +246,7 @@
|
|
|
246
246
|
* when another MCP server named homie has taken the connector's name there.
|
|
247
247
|
*
|
|
248
248
|
* homie-studio codex new <id> [--kind app|game] [--name "<Name>"]
|
|
249
|
-
* games/<id>/CODEX.md: the
|
|
249
|
+
* apps/<id>/CODEX.md for apps; games/<id>/CODEX.md for games: the Codex, every section, in the game's colours (a game
|
|
250
250
|
* is planned before it is made: with no game <id> yet, it starts its folder)
|
|
251
251
|
* homie-studio codex <id> [--artifact] [--open]
|
|
252
252
|
* the codex as a page in the game's own look (.studio/codex/<id>.html; it redraws
|
|
@@ -788,7 +788,11 @@ function print(result) {
|
|
|
788
788
|
`Written: ${result.file}`);
|
|
789
789
|
break;
|
|
790
790
|
case 'check':
|
|
791
|
-
if (result.kind === 'app') {
|
|
791
|
+
if (result.kind === 'app') {
|
|
792
|
+
if (result.needs?.length) lines.push(`PASS: app ${result.game ?? ''}: ${result.needs.length} Codex needs checked on ${(result.surfaces ?? []).join(', ')}.`);
|
|
793
|
+
if (result.reconnect) lines.push(`PASS: app ${result.game ?? ''}: wall and two phones shared an action; reconnect passed.`, ` ${result.before} → ${result.after}`);
|
|
794
|
+
break;
|
|
795
|
+
}
|
|
792
796
|
if (result.results) { for (const r of result.results) lines.push(`${r.game}: PASS at ${r.site}: room ${r.room}, round ${r.round?.n} (${r.round?.humans} humans, ${r.round?.bots} bots), connection ${r.uninterrupted === true ? 'uninterrupted' : r.uninterrupted === false ? 'INTERRUPTED' : 'unknown'}`); break; }
|
|
793
797
|
if (result.site) lines.push(`Checked ${result.local ? 'this studio\'s own build' : 'the site'} at ${result.site}.`);
|
|
794
798
|
lines.push(`PASS: two fresh browsers in room ${result.room} finished round ${result.round.n} (${result.round.humans} humans, ${result.round.bots} bots) in ${Math.round(result.totalMs / 1000)} s.`,
|
|
@@ -951,7 +955,7 @@ async function main() {
|
|
|
951
955
|
const { withKey } = await import('../lib/office.mjs');
|
|
952
956
|
const id = positional[2]; const role = positional[3];
|
|
953
957
|
if (!/^[a-z0-9][a-z0-9-]{0,39}$/.test(id ?? '') || !/^[a-z][a-z0-9-]{0,39}$/.test(role ?? '')) throw new Error('app role <id> <role> [--player <account>] [--revoke] [--url <site>]');
|
|
954
|
-
return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke') }));
|
|
958
|
+
return withKey(root, flags.get('url'), (call) => call(`/${id}/api/app/roles`, { role, studio:flags.has('studio'), ...(flags.get('player') ? { player: flags.get('player') } : {}), revoke: flags.has('revoke') }));
|
|
955
959
|
}
|
|
956
960
|
if (cmd === 'app' && sub === 'new') return newApp(root, positional[2], { name: flags.get('name'), from: flags.get('from') ?? 'welcome' });
|
|
957
961
|
if (cmd === 'game' && sub === 'new') return newGame(root, positional[2], { from: flags.get('from') ?? 'gem-rush', name: flags.get('name') });
|
|
@@ -1001,7 +1005,7 @@ async function main() {
|
|
|
1001
1005
|
const results = [];
|
|
1002
1006
|
for (const game of games) {
|
|
1003
1007
|
const shots = flags.get('shots') ? resolve(flags.get('shots'), ...(games.length > 1 ? [game] : [])) : null;
|
|
1004
|
-
const r = await tracked(root, 'checks', (report) => check({ root, url: site.url, game, shots, log, report }), 'check');
|
|
1008
|
+
const r = await tracked(root, 'checks', (report) => check({ root, url: site.url, game, shots, log, report, isolate:!flags.has('no-isolate') }), 'check');
|
|
1005
1009
|
results.push({ ...r, game, site: site.url, local: site.local });
|
|
1006
1010
|
const receiptDir = join(root, '.studio', 'checks');
|
|
1007
1011
|
mkdirSync(receiptDir, { recursive: true });
|
package/functions/FUNCTIONS.md
CHANGED
|
@@ -8,8 +8,9 @@ homie-studio dev
|
|
|
8
8
|
homie-studio function fire order.paid --id local-order-1 --input '{"order":"example"}'
|
|
9
9
|
```
|
|
10
10
|
|
|
11
|
-
The fire command
|
|
12
|
-
approval.
|
|
11
|
+
The fire command uses the private credential created by `dev`, without browser
|
|
12
|
+
approval. Local `tool call` uses the same headless connection. It is an owner tool
|
|
13
|
+
and refuses remote hosts; production connections still require OAuth. It records an event in the
|
|
13
14
|
same durable dispatcher as real events; firing the same ID again is a duplicate.
|
|
14
15
|
|
|
15
16
|
```ts
|
|
@@ -346,3 +347,39 @@ local webhook declaration. Build, commit and deploy the removal to retire the
|
|
|
346
347
|
subscription and pending retries; in-flight requests cannot be recalled. Adding
|
|
347
348
|
again creates a new registration and key, so old pending deliveries cannot be sent
|
|
348
349
|
to a replacement receiver. The feed's MCP subscriptions remain available.
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
## Concurrent storage and logs
|
|
353
|
+
|
|
354
|
+
`context.database` is scoped to the function (tools can explicitly share a
|
|
355
|
+
namespace). `get(key)` returns its JSON value; `getVersioned(key)` returns
|
|
356
|
+
`{value,version}` or null. `put(key,value,{version})` compares and sets in one SQL
|
|
357
|
+
statement, returning `{ok:true,version}` or `{ok:false,conflict:true}`. Version 0
|
|
358
|
+
means create only. Omitting the version preserves unconditional replacement.
|
|
359
|
+
|
|
360
|
+
```ts
|
|
361
|
+
let row = await context.database.getVersioned('total');
|
|
362
|
+
while (!(await context.database.put('total', Number(row?.value ?? 0) + 1,
|
|
363
|
+
{ version: row?.version ?? 0 })).ok) {
|
|
364
|
+
row = await context.database.getVersioned('total');
|
|
365
|
+
}
|
|
366
|
+
// Prefer a single atomic statement for these operations:
|
|
367
|
+
await context.database.increment('total', 1);
|
|
368
|
+
await context.database.append('selected-items', event.data);
|
|
369
|
+
const page = await context.database.list({prefix:'booking:', after:'', limit:100});
|
|
370
|
+
// page.items: {key,value,version}[], page.next: next cursor or null.
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
These operations prevent lost writes between Workers; they do not make an
|
|
374
|
+
at-least-once event exactly once. Use event IDs for deduplication. Atomic append is
|
|
375
|
+
for application arrays, not an ever-growing operational log. Declare a studio event
|
|
376
|
+
feed for logs and read pages using `studio_events_read` or resources. The owner can
|
|
377
|
+
clear retained event/delivery history through an explicit sequence with
|
|
378
|
+
`studio_events_clear`; no retention limit is imposed by default.
|
|
379
|
+
|
|
380
|
+
A booking payment can be tested with:
|
|
381
|
+
`homie-studio function fire order.paid --id fitting-paid-1 --input
|
|
382
|
+
'{"order":"local-order","app":"boards","reference":"booking-1"}'`.
|
|
383
|
+
Create any records the handler reads first. First fire registers subscriptions
|
|
384
|
+
before writing the event, so the first event is delivered too. No Stripe checkout
|
|
385
|
+
or browser approval is needed for this explicitly synthetic local event.
|