@homie-rocks/studio 0.50.6 → 0.51.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/app-starters/giving/README.md +25 -2
  3. package/app-starters/giving/app.json +16 -49
  4. package/app-starters/giving/index.html +1 -1
  5. package/app-starters/giving/src/rules.ts +1 -1
  6. package/app-starters/giving/src/view.ts +57 -59
  7. package/apps/APPS.md +70 -2
  8. package/apps/apps.ts +1 -1
  9. package/bin/homie-studio.mjs +8 -4
  10. package/functions/FUNCTIONS.md +39 -2
  11. package/guides/app/SKILL.md +61 -5
  12. package/guides/functions/SKILL.md +30 -0
  13. package/guides/office/SKILL.md +20 -0
  14. package/guides/servers/SKILL.md +18 -0
  15. package/guides/shop/SKILL.md +41 -20
  16. package/lib/app-needs.mjs +31 -11
  17. package/lib/app-ready.mjs +49 -0
  18. package/lib/check.mjs +51 -35
  19. package/lib/dev-stripe-fixture.mjs +37 -0
  20. package/lib/dev-stripe-sandbox.mjs +80 -0
  21. package/lib/dev.mjs +19 -17
  22. package/lib/events-cli.mjs +5 -1
  23. package/lib/local-test.mjs +18 -0
  24. package/lib/mcp-tools.mjs +15 -1
  25. package/lib/office.mjs +1 -1
  26. package/lib/scaffold.mjs +22 -1
  27. package/lib/shop-links.mjs +79 -7
  28. package/lib/shop.mjs +4 -2
  29. package/lib/stripe-connect.mjs +1 -0
  30. package/lib/template-history.json +84 -0
  31. package/lib/tools-cli.mjs +3 -0
  32. package/package.json +1 -1
  33. package/shop/SELLING.md +2 -2
  34. package/shop/SHOP.md +120 -5
  35. package/shop/shop.ts +29 -0
  36. package/tools/tools.ts +8 -1
  37. package/worker/app-format.mjs +8 -1
  38. package/worker/app-frame.mjs +9 -0
  39. package/worker/app-records.mjs +39 -10
  40. package/worker/booking-store.mjs +79 -0
  41. package/worker/build-id.mjs +1 -1
  42. package/worker/event-resources.mjs +30 -3
  43. package/worker/functions.mjs +5 -2
  44. package/worker/index.mjs +17 -3
  45. package/worker/local-test.mjs +19 -0
  46. package/worker/mcp-tools.mjs +19 -8
  47. package/worker/mcp.mjs +23 -11
  48. package/worker/records-page.mjs +13 -0
  49. package/worker/shop-links.mjs +3 -2
  50. package/worker/shop-page.mjs +27 -5
  51. package/worker/shop-rules.mjs +2 -1
  52. package/worker/shop-store.mjs +1 -1
  53. package/worker/shop-subscriptions.mjs +198 -0
  54. package/worker/shop.mjs +195 -29
  55. package/worker/tool-database.mjs +36 -0
  56. package/worker/version.mjs +1 -1
package/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
- Edit app.json words and starter settings. Phone: /<id>/open; wall: /<id>/tv. A site/pages/<id>/index.html replaces only the landing. Records are public; use references, never private contact details. Staff updates require an account grant.
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
- Giving totals are public intentions, not received money. The gift link opens the studio shop. Use `shop init --giving --currency CAD` and connect the organisation’s Stripe account. Monthly renewals are managed in Stripe.
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 goal with a one-time or monthly gift. Intentions below are not payments.",
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": "Record my intention",
41
- "total": "Giving intentions",
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": "Intention goal \u00b7 not payments received",
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 goal with a one-time or monthly gift. Intentions below are not payments."
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:58px}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}}
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 { createAppRecords, type AppRecord } from '@homie-rocks/studio/apps';
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 net = room.net;
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
- document.body.className = role;
17
- document.body.style.setProperty('--tone', config.color);
18
- $('h1').textContent = app.name; $('#intro').textContent = app.blurb;
19
- $('#label').textContent = words.total; action.textContent = words.action;
20
- $('#choice-label').textContent = words.choice;
21
- $('#frequency-label').textContent = words.monthly;
22
- $('#once').textContent = words.once; $('#month').textContent = words.month;
23
- $('#giving-link').textContent = words.give;
24
- $('#giving-link').hidden = config.kind !== 'giving';
25
- $('#frequency').hidden = config.kind !== 'giving';
26
- $('#goal').hidden = config.kind !== 'giving';
27
- $('#controls').hidden = role === 'wall';
28
- const select = $<HTMLSelectElement>('#choice');
29
- function choices() {
30
- select.replaceChildren();
31
- const list = config.kind === 'quiz' ? config.questions[question]?.choices ?? [] : config.choices;
32
- for (const value of list) { const option = document.createElement('option'); option.value = value; option.textContent = value; select.append(option); }
33
- $('#prompt').textContent = config.kind === 'quiz' ? config.questions[question]?.text ?? words.finished : words.prompt;
34
- action.hidden = config.kind === 'quiz' && question >= config.questions.length;
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
- $('[data-count]').textContent = String(rows.length);
39
- const amount = rows.reduce((n, r) => n + r.data.amount, 0);
40
- $('#amount').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(amount);
41
- $('#target').textContent = new Intl.NumberFormat(undefined, { style: 'currency', currency: config.currency }).format(config.goal);
42
- $<HTMLProgressElement>('progress').max = config.goal; $<HTMLProgressElement>('progress').value = amount;
43
- $('#goal-label').textContent = words.goal;
44
- action.disabled = busy || !ready;
45
- $('#entries').replaceChildren();
46
- for (const row of rows.slice(-6).reverse()) {
47
- const li = document.createElement('li');
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
- async function refresh() {
57
- try { rows = await records.list(); ready = true; paint(); net.playable(); }
58
- catch (error) { $('#status').textContent = (error as Error).message; }
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
- const choice = select.value;
64
- await records.create({ choice: config.kind === 'quiz' ? `${question + 1}. ${choice}` : choice, status: words.pending, amount: config.kind === 'giving' ? Number(choice) : 0, monthly: $<HTMLSelectElement>('#monthly').value === 'month' }, randomId());
65
- $('#status').textContent = words.thanks;
66
- if (config.kind === 'quiz') { question++; choices(); }
67
- room.command('refresh', {}); await refresh();
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
- room.on('refresh', () => { void refresh(); });
72
- net.on('link', () => { void refresh(); });
73
- setInterval(() => { $('#connection').textContent = net.link === 'online' ? words.live : words.reconnecting; void refresh(); }, 2000);
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. This performs the declared action and writes real records: use a local/test studio or a workflow explicitly safe to exercise.
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 hide this button (merge with any existing screen settings). The room panel and your own shop buttons still work. Closed shops, kids policies and an empty selection show no button.
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> }>();
@@ -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 Game Codex, every section, in the game's colours (a game
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') { lines.push(`PASS: app ${result.game ?? ''}: wall and two phones shared an action; reconnect ${result.reconnect ? 'passed' : 'failed'}.`, ` ${result.before} → ${result.after}`); break; }
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 });
@@ -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 connects to the local studio through the existing browser OAuth
12
- approval. It is an owner tool and refuses remote hosts. It records an event in the
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.